From 99e8d9083775873c0a6e4334c5a183a983213873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:42:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EC=9D=84=20=ED=97=88=EC=9A=A9=C2=B7=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20=ED=95=9C=20=EC=B6=95=EC=9C=BC=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=ED=95=98=EA=B3=A0=20core=20=EC=97=90=EC=84=9C=20headl?= =?UTF-8?q?ess=20=EC=96=B4=ED=9C=98=EB=A5=BC=20=EA=B1=B7=EC=96=B4=EB=82=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 route 3값(SUPPORTED·HEADLESS_FIRST·UNSUPPORTED)과 허가 boolean 은 서로 다른 질문(등록을 받나 / 어떻게 가져오나 / 실측했나 / 브라우저를 써도 되나)에 한 축으로 답하고 있었다. 그래서 '허가 없는 HEADLESS_FIRST' 같은 모순 조합이 DB 에 저장 가능했고 실제로 dev 에 좀비 행 3개가 있었다 - 축을 하나로 좁힌다: DomainAccess { ALLOWED, BLOCKED }. 행이 없으면 기본 흐름이라 정책 테이블은 예외만 담고, 두 값 모두 실제 동작을 가져 기록 전용 값이 사라진다. 모순 조합은 표현 자체가 불가능해진다 - '어떻게 가져오나'(HEADLESS_FIRST)는 최적화였고 불완전 승격이 들어온 뒤로는 정책 없이도 같은 결과에 도달해 값을 넣을 유인이 사라졌다. 축에서 제거하고, 필요해지면 별도 컬럼으로 되살린다(성격이 다른 것을 한 축에 넣지 않는다) - BLOCKED 검사를 extractor 로 나가는 유일한 출구(HttpProductLinkExtractor)에 둔다. 등록 경계만 막던 종전 구조는 차단 지정 이전에 담긴 아이템의 재파싱·새로고침이 그대로 나갔다 - '거부 의사를 확인한 곳에 매번 다시 두드리지 않는다'는 계약이라 성능 판단이 아니다 - ALLOWED 는 '플랫폼의 명시적 허락을 받았다'는 뜻이고 그 결과로 렌더 서비스의 우회 수단이 열린다. 근거(permissionRef) 없이는 켤 수 없게 입력 경계와 엔티티 불변식 양쪽에서 막는다 - 테이블을 이관하지 않고 새로 만든다. 같은 HEADLESS_FIRST 라도 kream 은 차단이고 store.kakao 는 차단이 아니라 기계적 매핑이 성립하지 않는다. 시드도 두지 않는다 - 근거 없이 채우면 좀비 행이 생긴 경로를 그대로 반복한다 - 백오피스 3열을 2열로 개편하고 값 이름이 그대로 박혀 있던 CSS 클래스까지 함께 고친다. 허가 체크박스는 ALLOWED 값에 흡수됐다 - 옛 테이블은 이 마이그레이션에서 지우지 않는다. 코드가 참조를 끊은 배포가 한 바퀴 돈 뒤 별도 마이그레이션으로 제거한다(blue-green 공존 구간에서 구버전이 아직 읽는다) --- .../AdminExtractionPolicyController.kt | 55 ++--- .../AdminExtractionPolicyService.kt | 121 ++++------ .../common/ratelimit/ItemQuotaProperties.kt | 2 +- .../piki/product/routing/DomainAccess.kt | 17 ++ .../product/routing/DomainAccessPolicy.kt | 99 ++++++++ .../routing/DomainAccessPolicyEntity.kt | 44 ++++ ....kt => DomainAccessPolicyJpaRepository.kt} | 2 +- .../routing/ExtractionPlatformPolicyEntity.kt | 54 ----- .../piki/product/routing/ExtractionRoute.kt | 29 --- .../routing/ExtractionRoutingPolicy.kt | 99 -------- .../remote/HttpProductLinkExtractor.kt | 45 ++-- .../service/TournamentItemService.kt | 6 +- .../piki/wishlist/service/WishlistService.kt | 6 +- src/main/resources/application.yml | 2 +- ...extraction_policies_with_domain_access.sql | 28 +++ .../templates/admin/extraction-policies.html | 61 +++-- .../admin/extraction-policy-detail.html | 53 ++--- .../AdminExtractionPolicyIntegrationTest.kt | 223 ++++-------------- .../routing/DomainAccessPolicyEntityTest.kt | 76 ++++++ ...t => DomainAccessPolicyIntegrationTest.kt} | 125 ++++------ .../ExtractionPlatformPolicyEntityTest.kt | 64 ----- .../remote/HttpProductLinkExtractorTest.kt | 116 +++------ .../controller/TournamentIntegrationTest.kt | 19 +- .../WishlistRegisterAsyncIntegrationTest.kt | 19 +- 24 files changed, 565 insertions(+), 800 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/product/routing/DomainAccess.kt create mode 100644 src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicy.kt create mode 100644 src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntity.kt rename src/main/kotlin/com/depromeet/piki/product/routing/{ExtractionPlatformPolicyJpaRepository.kt => DomainAccessPolicyJpaRepository.kt} (50%) delete mode 100644 src/main/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyEntity.kt delete mode 100644 src/main/kotlin/com/depromeet/piki/product/routing/ExtractionRoute.kt delete mode 100644 src/main/kotlin/com/depromeet/piki/product/routing/ExtractionRoutingPolicy.kt create mode 100644 src/main/resources/db/migration/V20260816090000__replace_extraction_policies_with_domain_access.sql create mode 100644 src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntityTest.kt rename src/test/kotlin/com/depromeet/piki/product/routing/{ExtractionRoutingPolicyIntegrationTest.kt => DomainAccessPolicyIntegrationTest.kt} (57%) delete mode 100644 src/test/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyEntityTest.kt 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..9130e195d 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 @@ -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 의 기본값 = 거부). 허가는 "메일로 받아 원장에 남기는" 조작이라 // 근거 입력·현재 상태 확인이 있는 상세 화면의 몫이다 — 목록의 한 줄짜리 폼에서 지나가듯 켤 일이 아니다. - // 그래서 여기서 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}") @@ -71,15 +71,12 @@ class AdminExtractionPolicyController( // 상세의 정책·사유·헤드리스 허가 수정. save 가 upsert 라 추가 폼과 같은 경로를 탄다 (도메인은 PK 라 상세에서 // 바꾸지 않는다). 정책과 사유를 한 폼으로 받는 이유: 정책이 바뀌는 순간이 곧 근거가 새로 필요한 순간이라, 둘이 // 따로 저장되면 "403 봇 차단" 사유가 SUPPORTED 행에 남는 식으로 근거가 정책과 어긋난다. 허가도 같은 폼에 둔다 — - // 허가와 정책이 따로 저장되면 "허가 없는 HEADLESS_FIRST" 같은 어긋난 중간 상태를 지나야 한다. // - // headlessAllowed 는 체크박스라 끄면 파라미터 자체가 오지 않는다 — 없음을 곧 거부(false)로 읽는다(default-deny). @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 +84,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 +97,7 @@ class AdminExtractionPolicyController( detailView( adminExtractionPolicyService.find(domain), model, - selectedRoute = route.name, - draftHeadlessAllowed = headlessAllowed ?: false, + selectedAccess = access.name, draftPermissionRef = permissionRef, ) } @@ -123,44 +118,42 @@ class AdminExtractionPolicyController( // 보드 화면의 모델 채우기 단일 지점 — 정상 목록과 두 에러 재표시 경로가 공유한다 // (한쪽만 갱신돼 에러 화면에서 모델이 비는 함정 방지). - // selectedRoute 기본값이 SUPPORTED 인 이유: 추가 폼의 기본 선택이 곧 오조작 시 저장되는 값이라, + // selectedAccess 기본값이 SUPPORTED 인 이유: 추가 폼의 기본 선택이 곧 오조작 시 저장되는 값이라, // 라우팅을 바꾸지 않는 값(기록용)을 기본에 둔다. UNSUPPORTED 가 기본이면 실수 한 번이 등록 차단이 된다. // 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 와 같은 방식으로 컨트롤러가 확정해 넘긴다 — 에러 재표시에서는 방금 제출한 + // 허가 입력값은 selectedAccess 와 같은 방식으로 컨트롤러가 확정해 넘긴다 — 에러 재표시에서는 방금 제출한 // 값을, 평소에는 저장된 값을 보여준다(체크 해제는 draft 가 false 라 그대로 유지된다). 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..959b59d79 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..f506468da 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt @@ -19,7 +19,7 @@ import java.time.Duration // (그 item 은 위시에 담길 때 이미 한 번 깎였다). // // 기준은 "LLM 을 타는가" 가 아니라 **"외부에 돈이 나가는가"** 다. 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타도 -// fetch 대역·residential proxy 요청(HEADLESS_FIRST 사이트)·헤드리스 렌더러 시간·이미지 저장·DB 행 영구 증가를 +// fetch 대역·렌더러 시간(허락받은 도메인은 프록시까지)·이미지 저장·DB 행 영구 증가를 // 소모한다. 그래서 경로별 차등 없이 균일하게 1 을 센다 — 애초에 등록 시점엔 파서로 풀릴지 LLM 으로 갈지 알 수 없고, // 사이트가 마크업을 바꾸면 어제 파서로 풀리던 링크가 오늘 LLM 을 탄다. // 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..496d469bd --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccess.kt @@ -0,0 +1,17 @@ +package com.depromeet.piki.product.routing + +// 도메인 하나를 어떻게 대하는가. 축은 이것 하나이고 값은 둘뿐이다 — 행이 없으면 "아직 판단하지 않음"이라 +// 기본 흐름(정적 fetch -> 필요하면 브라우저 승격)을 그대로 탄다. +// +// 옛 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..5a72d8059 --- /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..eefb3207a --- /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/remote/HttpProductLinkExtractor.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractor.kt index 33473ad00..a553a2ee0 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 로 나간다. 이 값이 여는 것은 렌더 서비스의 우회 수단(지문 보정·프록시)뿐이고, 브라우저를 여는 것 자체는 +// 허락과 무관하다 — 신원을 밝히고 우리 IP 로 가는 일이라 전제가 필요 없다. // // 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/tournament/service/TournamentItemService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt index 59b29e8c1..1c923919f 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 extractionRoutingPolicy: DomainAccessPolicy, private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, private val tournamentRepository: TournamentRepository, @@ -53,7 +53,7 @@ class TournamentItemService( ): Long { val link = ProductLink.parse(url) // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다(400). - // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — ExtractionRoutingPolicy 참고. + // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — DomainAccessPolicy 참고. extractionRoutingPolicy.verifyRegistrable(link) // 권한·상태를 차감 전에 확인한다(이미지 경로와 같은 이유) — 참여자도 아닌 요청이 오너의 몫을 깎으면 안 된다. // persist 안에서 정원까지 포함해 최종 판정을 다시 하므로 여기 검증은 사전 확인이다. 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..26e2d959b 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 extractionRoutingPolicy: DomainAccessPolicy, private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, private val wishRepository: WishRepository, @@ -64,7 +64,7 @@ class WishlistService( requireMember(userId) val link = ProductLink.parse(rawUrl) // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다. - // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — ExtractionRoutingPolicy 참고. + // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — DomainAccessPolicy 참고. extractionRoutingPolicy.verifyRegistrable(link) // 형식·플랫폼 검증(400)을 통과한 뒤에 차감한다 — 잘못된 URL 로 한도를 깎으면 사용자가 자기 실수로 몫을 잃는다. // 파서로 풀려 LLM 을 안 타도 fetch·프록시·저장·DB 행은 그대로 소모되므로 경로와 무관하게 1 로 센다. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1767e88da..6e7ccd3d5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -169,7 +169,7 @@ s3: # 큐에 넣는 item 수다(이미지 5장 = 5). 값은 env 로 재정의해 배포 없이 조일 수 있다. # # "LLM 을 타는가" 가 아니라 "외부에 돈이 나가는가" 가 기준이다. 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타더라도 -# fetch 대역, residential proxy 요청(HEADLESS_FIRST 사이트), 헤드리스 렌더러 시간, 이미지 저장, DB 행 영구 증가를 +# fetch 대역, 렌더러 시간(허락받은 도메인은 프록시까지), 이미지 저장, DB 행 영구 증가를 # 소모한다. 그래서 LLM 여부로 차등하지 않고 등록 1건을 균일하게 센다 — 등록 시점엔 어느 경로로 풀릴지 알 수도 없다. # # 무엇이 차감 대상인지의 기준은 "새 파싱 작업이 큐에 들어가는가" 하나다. 그래서 새로고침은 파싱이 한 번 더 도므로 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..2c836b8a8 --- /dev/null +++ b/src/main/resources/db/migration/V20260816090000__replace_extraction_policies_with_domain_access.sql @@ -0,0 +1,28 @@ +-- 플랫폼 정책 테이블을 "허용/차단" 한 축으로 교체한다. +-- +-- 왜 이관하지 않고 새로 만드나: 옛 route 3값(SUPPORTED·HEADLESS_FIRST·UNSUPPORTED)과 허가 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..d053e1c42 100644 --- a/src/main/resources/templates/admin/extraction-policies.html +++ b/src/main/resources/templates/admin/extraction-policies.html @@ -32,15 +32,13 @@ border-left:1px solid var(--border);white-space:nowrap;cursor:pointer;transition:background .12s,color .12s;} .segopt:first-child span{border-left:none;} .segopt span:hover{background:#f7f8fa;} - .segopt input:checked + span.SUPPORTED{background:var(--ok-soft);color:var(--ok);} - .segopt input:checked + span.HEADLESS_FIRST{background:var(--violet-soft);color:var(--violet);} - .segopt input:checked + span.UNSUPPORTED{background:var(--danger-soft);color:var(--danger);} + .segopt input:checked + span.ALLOWED{background:var(--violet-soft);color:var(--violet);} + .segopt input:checked + span.BLOCKED{background:var(--danger-soft);color:var(--danger);} .segopt input:focus-visible + span{outline:2px solid var(--accent);outline-offset:-2px;} .badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;white-space:nowrap;} - .badge.SUPPORTED{background:var(--ok-soft);color:var(--ok);} - .badge.HEADLESS_FIRST{background:var(--violet-soft);color:var(--violet);} - .badge.UNSUPPORTED{background:var(--danger-soft);color:var(--danger);} + .badge.ALLOWED{background:var(--violet-soft);color:var(--violet);} + .badge.BLOCKED{background:var(--danger-soft);color:var(--danger);} /* 정책 행이 없는 도메인. 목록에 나타날 수 없으므로 범례에서만 쓰는 배지. 점선으로 "행이 없음"을 드러낸다. */ .badge.none{background:#fff;color:#9aa1aa;border:1px dashed #ccd2d9;} .badge.unk{background:var(--warn-soft);color:var(--warn);border:1px solid var(--warn-border);} @@ -71,7 +69,7 @@ nth-child(2) 가 다른 열을 가리킨다. */ .gtable .c-policy{width:148px;} .gtable .c-how{width:214px;color:#384049;font-weight:600;} - /* "정책 없음"과 SUPPORTED 는 추출 방법이 같아 한 칸으로 묶는다. 같다는 사실을 문장으로 반복하지 않고 + /* 같은 추출 방법을 쓰는 행끼리 칸을 묶는다. 같다는 사실을 문장으로 반복하지 않고 표 구조로 드러낸다. 병합 칸은 두 행에 걸치므로 가운데 정렬이 자연스럽다. */ .gtable td[rowspan]{vertical-align:middle;} .gtable .same{display:block;margin-top:6px;font-weight:500;font-size:11.5px;color:var(--muted);} @@ -112,7 +110,7 @@ @media (hover:none){.row .go{opacity:1;}} .empty{padding:13px 14px;font-size:12.5px;color:var(--muted);} - /* 이 서버가 모르는 route 값의 열. 별도 경고 박스로 화면을 압도하지 않고 다른 갈래와 같은 모양의 열로 붙인다. + /* 이 서버가 모르는 정책 값의 열. 별도 경고 박스로 화면을 압도하지 않고 다른 갈래와 같은 모양의 열로 붙인다. 세 갈래 어디에도 속하지 않아 열에서 빠뜨리면 백오피스에서 보이지도 지워지지도 않는 유령 행이 되므로, 조용하되 반드시 보이게 둔다. route 로 필터할 수 없으므로 헤더는 링크가 아니다. */ .col.unk{border-color:var(--warn-border);} @@ -128,40 +126,35 @@

추출 라우팅 정책

배포 없이 플랫폼(도메인)별 추출 처리를 바꿉니다. 서브도메인은 자동 포함됩니다 (coupang.com 은 www·m 포함).
같은 도메인을 다시 추가하면 정책이 교체되고, 수정·삭제는 항목을 눌러 상세에서 합니다.
- 브라우저(헤드리스)로 여는 것은 허가받은 도메인만 됩니다 (기본은 거부). 허가는 상세에서 근거와 함께 켜고, 목록에는 허가 로 표시됩니다.
+ 정책이 없는 도메인이 기본입니다 — 등록을 받고, 정적 fetch 로 안 되면 브라우저로 다시 시도합니다.
사유에는 날짜를 적지 말고 관찰한 내용만 남깁니다. 언제 바뀌었는지는 수정일이 자동으로 기록합니다.

- 정책 종류 설명 정책 없음 · SUPPORTED · HEADLESS_FIRST · UNSUPPORTED + 정책 종류 설명 정책 없음 · ALLOWED · BLOCKED
- + - - + + - - + + + - - - - - - - - + + + @@ -185,8 +178,8 @@

추출 라우팅 정책

-
@@ -202,9 +195,9 @@

추출 라우팅 정책

-
@@ -229,7 +222,7 @@

추출 라우팅 정책

- +
diff --git a/src/main/resources/templates/admin/extraction-policy-detail.html b/src/main/resources/templates/admin/extraction-policy-detail.html index e26020488..ac97b1a83 100644 --- a/src/main/resources/templates/admin/extraction-policy-detail.html +++ b/src/main/resources/templates/admin/extraction-policy-detail.html @@ -15,9 +15,8 @@ .domain{font-family:ui-monospace,Menlo,monospace;font-size:17px;font-weight:800;color:#1f2937;word-break:break-all;} .meta{display:flex;align-items:center;gap:10px;margin-top:9px;font-size:12.5px;color:var(--muted);} .badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;white-space:nowrap;} - .badge.SUPPORTED{background:var(--ok-soft);color:var(--ok);} - .badge.HEADLESS_FIRST{background:var(--violet-soft);color:var(--violet);} - .badge.UNSUPPORTED{background:var(--danger-soft);color:var(--danger);} + .badge.ALLOWED{background:var(--violet-soft);color:var(--violet);} + .badge.BLOCKED{background:var(--danger-soft);color:var(--danger);} .badge.unknown{background:var(--warn-soft);color:var(--warn);border:1px solid var(--warn-border);} .card{background:#fff;border:1px solid var(--border);border-radius:14px;padding:18px;margin-top:18px;} @@ -37,7 +36,7 @@ .hint a:hover{text-decoration:underline;} /* 보드의 추가 폼과 같은 세그먼트 컨트롤. 갈래 셋을 펼쳐 두고 고른 칸만 배지 색으로 채운다. - 모르는 route(구버전 롤백) 행이면 어느 칸도 선택되지 않으므로, radio 의 required 가 저장을 막아 + 모르는 정책 값(구버전 롤백) 행이면 어느 칸도 선택되지 않으므로, radio 의 required 가 저장을 막아 운영자가 알려진 정책을 명시적으로 고르게 한다. */ .seg{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden;} .segopt{display:inline-flex;position:relative;margin:0;} @@ -46,9 +45,8 @@ border-left:1px solid var(--border);white-space:nowrap;cursor:pointer;transition:background .12s,color .12s;} .segopt:first-child span{border-left:none;} .segopt span:hover{background:#f7f8fa;} - .segopt input:checked + span.SUPPORTED{background:var(--ok-soft);color:var(--ok);} - .segopt input:checked + span.HEADLESS_FIRST{background:var(--violet-soft);color:var(--violet);} - .segopt input:checked + span.UNSUPPORTED{background:var(--danger-soft);color:var(--danger);} + .segopt input:checked + span.ALLOWED{background:var(--violet-soft);color:var(--violet);} + .segopt input:checked + span.BLOCKED{background:var(--danger-soft);color:var(--danger);} .segopt input:focus-visible + span{outline:2px solid var(--accent);outline-offset:-2px;} .btn{margin-top:18px;background:var(--accent);color:#fff;border:none;border-radius:10px;padding:12px 22px;font-size:14px;font-weight:700;font-family:inherit;cursor:pointer;} .btn.ghost{background:#fff;color:var(--muted);border:1px solid var(--border);margin-left:8px;text-decoration:none;display:inline-block;padding:11px 18px;} @@ -68,15 +66,13 @@
example.com
- ROUTE - 헤드리스 불가 + ACCESS 마지막 수정 -
에러
-
+
이 서버가 모르는 정책입니다. 저장된 정책이 이 서버가 아는 세 가지 중 어느 것도 아닙니다. 지금은 정책이 없는 것과 똑같이 기본 추출로 처리되며, 사용자 등록이 막히지는 않습니다. 아래에서 아는 정책을 골라 저장하거나, 필요 없으면 이 행을 삭제하세요. @@ -85,8 +81,8 @@
정책
-
@@ -100,25 +96,12 @@
정책을 바꿀 때 근거도 함께 고쳐 주세요. 사유가 옛 정책의 것으로 남으면 다음 사람이 잘못된 근거를 믿게 됩니다.
날짜는 적지 않습니다. 언제 바뀌었는지는 위의 수정일이 답합니다.
- -
헤드리스 허가
- -
기본은 거부입니다. 플랫폼에서 명시적으로 허가를 받은 도메인만 켭니다. - HEADLESS_FIRST 정책은 이 허가가 켜져 있어야 저장됩니다.
- - + -
허가를 켜려면 근거가 필요합니다. 나중에 누가 언제 무엇을 근거로 열었는지 되짚을 수 있게 - 메일 스레드·수신일·담당자를 남겨 주세요. - 현재 허가 시각: - -
+
ALLOWED 로 지정하려면 근거가 필요합니다. 이 값은 우회 수단(프록시 등)을 여는 값이라, + 나중에 누가 언제 무엇을 근거로 열었는지 되짚을 수 있어야 합니다 — 메일 스레드·수신일·담당자를 남겨 주세요.
취소 @@ -128,14 +111,12 @@
정책 삭제
-
- 삭제해도 추출 방법은 지금과 같은 기본 추출입니다. 잘 된다고 실측으로 확인한 기록만 사라집니다. - 삭제하면 이 도메인은 정책 없음으로 돌아가, 브라우저로 여는 대신 기본 추출(정적 fetch 부터)을 탑니다. - 삭제 즉시 이 도메인의 사용자 등록이 다시 열립니다. 정책 없음으로 돌아가 기본 추출을 탑니다. +
+ 삭제하면 허락과 그 근거가 함께 사라집니다 — 이 도메인은 다시 우회 수단을 쓸 수 없고, + 기본 추출(정적 fetch → 안 되면 브라우저)만 탑니다. + 삭제 즉시 이 도메인의 사용자 등록이 다시 열립니다. 정책 없음으로 돌아가 기본 추출을 탑니다. 삭제해도 추출 동작은 그대로입니다 (모르는 정책은 이미 정책 없음처럼 처리됩니다). 남아 있던 이 행만 사라집니다. - 되돌리려면 정책을 새로 추가해야 합니다. - - 헤드리스 허가와 그 근거도 함께 사라집니다 — 이 도메인은 다시 브라우저로 열 수 없게 됩니다.
+ 되돌리려면 정책을 새로 추가해야 합니다.
diff --git a/src/test/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyIntegrationTest.kt index 5d29a7a50..681da9007 100644 --- a/src/test/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyIntegrationTest.kt @@ -1,10 +1,10 @@ package com.depromeet.piki.admin.extraction import com.depromeet.piki.product.domain.ProductLink -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.DomainAccessPolicyEntity +import com.depromeet.piki.product.routing.DomainAccessPolicyJpaRepository +import com.depromeet.piki.product.routing.DomainAccess import com.depromeet.piki.support.IntegrationTestSupport import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -35,10 +35,10 @@ class AdminExtractionPolicyIntegrationTest : IntegrationTestSupport() { private lateinit var webApplicationContext: WebApplicationContext @Autowired - private lateinit var policyRepository: ExtractionPlatformPolicyJpaRepository + private lateinit var policyRepository: DomainAccessPolicyJpaRepository @Autowired - private lateinit var routingPolicy: DbExtractionRoutingPolicy + private lateinit var accessPolicy: DbDomainAccessPolicy private fun mockMvc(): MockMvc = MockMvcBuilders @@ -57,68 +57,68 @@ class AdminExtractionPolicyIntegrationTest : IntegrationTestSupport() { .response.contentAsString @Test - fun `보드는 세 갈래 열을 모두 렌더하고, 사유는 목록에 노출하지 않는다`() { + fun `보드는 두 갈래 열을 모두 렌더하고, 사유는 목록에 노출하지 않는다`() { // 사유가 목록에 실리면 행이 세로로 벌어지고(개편 동기) 상세로 들어갈 이유도 사라진다. // 사유 전문은 상세 화면의 몫이라는 contract 를 여기서 고정한다. val mockMvc = mockMvc() val domain = "board-${UUID.randomUUID()}.example.com" val reason = "REASON-${UUID.randomUUID()}" try { - policyRepository.save(ExtractionPlatformPolicyEntity(domain = domain, route = ExtractionRoute.SUPPORTED.name, reason = reason)) + policyRepository.save(DomainAccessPolicyEntity(domain = domain, access = DomainAccess.ALLOWED.name, reason = reason, permissionRef = "test permission")) val body = html(mockMvc, "/admin/extraction-policies") assertContains(body, domain) - ExtractionRoute.entries.forEach { assertContains(body, it.name) } // 열 헤더 3개 + DomainAccess.entries.forEach { assertContains(body, it.name) } // 열 헤더 2개 assertFalse(body.contains(reason), "사유는 보드가 아니라 상세에서만 보여야 한다") } finally { policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() + accessPolicy.reload() } } @Test - fun `route 필터는 그 갈래만 남기고, 모르는 값이면 전체를 보여준다`() { - // ?route= 는 tolerant 하게 읽는다(옛 링크·손으로 고친 URL 이 400 으로 깨지지 않게). + fun `access 필터는 그 갈래만 남기고, 모르는 값이면 전체를 보여준다`() { + // ?access=는 tolerant 하게 읽는다(옛 링크·손으로 고친 URL 이 400 으로 깨지지 않게). // 시드 도메인(coupang.com)으로 단언하지 않는다 — 화면 설명·입력 placeholder 에 예시로 박혀 있어 // 필터와 무관하게 늘 HTML 에 등장하므로 거짓 통과·거짓 실패를 만든다. val mockMvc = mockMvc() val supported = "filter-ok-${UUID.randomUUID()}.example.com" val unsupported = "filter-no-${UUID.randomUUID()}.example.com" try { - policyRepository.save(ExtractionPlatformPolicyEntity(domain = supported, route = ExtractionRoute.SUPPORTED.name, reason = null)) - policyRepository.save(ExtractionPlatformPolicyEntity(domain = unsupported, route = ExtractionRoute.UNSUPPORTED.name, reason = null)) + policyRepository.save(DomainAccessPolicyEntity(domain = supported, access = DomainAccess.ALLOWED.name, reason = null, permissionRef = "test permission")) + policyRepository.save(DomainAccessPolicyEntity(domain = unsupported, access = DomainAccess.BLOCKED.name, reason = null)) - val filtered = html(mockMvc, "/admin/extraction-policies?route=SUPPORTED") + val filtered = html(mockMvc, "/admin/extraction-policies?access=ALLOWED") assertContains(filtered, supported) - assertFalse(filtered.contains(unsupported), "다른 갈래의 정책은 SUPPORTED 필터에 나오면 안 된다") + assertFalse(filtered.contains(unsupported), "다른 갈래의 정책은 ALLOWED 필터에 나오면 안 된다") - val tolerated = html(mockMvc, "/admin/extraction-policies?route=NOT_A_ROUTE") + val tolerated = html(mockMvc, "/admin/extraction-policies?access=NOT_AN_ACCESS") assertContains(tolerated, supported) assertContains(tolerated, unsupported) } finally { listOf(supported, unsupported).forEach { d -> policyRepository.findById(d).ifPresent { policyRepository.delete(it) } } - routingPolicy.reload() + accessPolicy.reload() } } @Test fun `모르는 정책은 전체 보기에만 나오고 필터를 걸면 숨는다`() { - // 모르는 정책은 route 로 지목할 수 없어 어떤 필터에도 속하지 않는다. 필터를 건 화면에까지 끼워 넣으면 + // 모르는 정책은 값으로 지목할 수 없어 어떤 필터에도 속하지 않는다. 필터를 건 화면에까지 끼워 넣으면 // "그 갈래만 본다"는 약속이 깨진다. 반대로 전체 보기에서도 빠지면 보이지도 지워지지도 않는 유령 행이 된다. val mockMvc = mockMvc() val domain = "filtered-unknown-${UUID.randomUUID()}.example.com" try { - policyRepository.save(ExtractionPlatformPolicyEntity(domain = domain, route = "FUTURE_ROUTE", reason = null)) + policyRepository.save(DomainAccessPolicyEntity(domain = domain, access = "FUTURE_ACCESS", reason = null)) assertContains(html(mockMvc, "/admin/extraction-policies"), domain) assertFalse( - html(mockMvc, "/admin/extraction-policies?route=SUPPORTED").contains(domain), + html(mockMvc, "/admin/extraction-policies?access=ALLOWED").contains(domain), "필터를 건 화면에는 모르는 정책이 나오면 안 된다", ) } finally { policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() + accessPolicy.reload() } } @@ -133,33 +133,33 @@ class AdminExtractionPolicyIntegrationTest : IntegrationTestSupport() { post("/admin/extraction-policies") .with(csrf()) .param("domain", "ADDED-${suffix.toString().uppercase()}.EXAMPLE.COM.") - .param("route", "UNSUPPORTED") + .param("access", "BLOCKED") .param("reason", "test reason"), ).andExpect(status().is3xxRedirection) .andExpect(redirectedUrl("/admin/extraction-policies?updated")) val saved = policyRepository.findById(domain).orElseThrow() - assertEquals(ExtractionRoute.UNSUPPORTED.name, saved.route) + assertEquals(DomainAccess.BLOCKED.name, saved.access) assertEquals("test reason", saved.reason) // afterCommit reload 로 배포 없이 곧바로 판정에 반영된다. - assertEquals(ExtractionRoute.UNSUPPORTED, routingPolicy.routeOf(ProductLink.parse("https://$domain/p"))) + assertEquals(DomainAccess.BLOCKED, accessPolicy.accessOf(ProductLink.parse("https://$domain/p"))) } finally { policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() + accessPolicy.reload() } } @Test fun `상세는 사유 전문을 싣고, 정책과 사유를 한 번에 교체한다`() { - // 정책이 바뀌는 순간이 곧 근거가 새로 필요한 순간이라 둘을 한 폼으로 받는다 — 따로 저장되면 - // "차단됨" 사유가 SUPPORTED 행에 남아 다음 사람이 잘못된 근거를 믿는다. + // 정책이 바뀌는 순간이 곧 근거가 새로 필요한 순간이라 셋(값·사유·허락 근거)을 한 폼으로 받는다 — + // 따로 저장되면 차단 사유가 허락 행에 남아 다음 사람이 잘못된 근거를 믿는다. val mockMvc = mockMvc() val domain = "detail-${UUID.randomUUID()}.example.com" val oldReason = "OLD-${UUID.randomUUID()}" val newReason = "NEW-${UUID.randomUUID()}" try { - policyRepository.save(ExtractionPlatformPolicyEntity(domain = domain, route = ExtractionRoute.UNSUPPORTED.name, reason = oldReason)) - routingPolicy.reload() + policyRepository.save(DomainAccessPolicyEntity(domain = domain, access = DomainAccess.BLOCKED.name, reason = oldReason)) + accessPolicy.reload() // 도메인에 점이 들어간 path variable 이 잘리지 않고 상세로 매핑된다. val detail = html(mockMvc, "/admin/extraction-policies/$domain") @@ -170,18 +170,19 @@ class AdminExtractionPolicyIntegrationTest : IntegrationTestSupport() { .perform( post("/admin/extraction-policies/$domain") .with(csrf()) - .param("route", "SUPPORTED") - .param("reason", newReason), + .param("access", "ALLOWED") + .param("reason", newReason) + .param("permissionRef", "partner mail thread"), ).andExpect(status().is3xxRedirection) .andExpect(redirectedUrl("/admin/extraction-policies?updated")) val saved = policyRepository.findById(domain).orElseThrow() - assertEquals(ExtractionRoute.SUPPORTED.name, saved.route) + assertEquals(DomainAccess.ALLOWED.name, saved.access) assertEquals(newReason, saved.reason) - assertEquals(ExtractionRoute.SUPPORTED, routingPolicy.routeOf(ProductLink.parse("https://$domain/p"))) + assertEquals(DomainAccess.ALLOWED, accessPolicy.accessOf(ProductLink.parse("https://$domain/p"))) } finally { policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() + accessPolicy.reload() } } @@ -190,8 +191,8 @@ class AdminExtractionPolicyIntegrationTest : IntegrationTestSupport() { val mockMvc = mockMvc() val domain = "deleted-${UUID.randomUUID()}.example.com" try { - policyRepository.save(ExtractionPlatformPolicyEntity(domain = domain, route = ExtractionRoute.UNSUPPORTED.name, reason = null)) - routingPolicy.reload() + policyRepository.save(DomainAccessPolicyEntity(domain = domain, access = DomainAccess.BLOCKED.name, reason = null)) + accessPolicy.reload() mockMvc .perform(post("/admin/extraction-policies/$domain/delete").with(csrf())) @@ -200,149 +201,17 @@ class AdminExtractionPolicyIntegrationTest : IntegrationTestSupport() { assertTrue(policyRepository.findById(domain).isEmpty) // 정책 없음 = 기본 체인. 삭제 즉시(afterCommit reload) 등록이 다시 열린다. - assertNull(routingPolicy.routeOf(ProductLink.parse("https://$domain/p"))) + assertNull(accessPolicy.accessOf(ProductLink.parse("https://$domain/p"))) } finally { policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() + accessPolicy.reload() } } - @Test - fun `시드 정책의 사유에는 날짜가 남아 있지 않다`() { - // "언제 바뀌었나"는 updated_at 하나가 답한다. 사유 문자열에도 날짜를 적으면 같은 사실이 두 곳에 살아 - // 한쪽만 고쳐질 때 조용히 어긋난다(SSOT 위반). 시드에 박혀 있던 날짜 접두사를 마이그레이션이 벗겼는지 - // 실제 DB 로 확인한다 — 마이그레이션이 빠지거나 되돌려지면 여기서 빨간불이 난다. - val seeded = listOf("kream.co.kr", "coupang.com", "naver.com", "naver.me", "oliveyoung.co.kr", "oy.run", "a-bly.com") - val datePattern = Regex("""\d{4}-\d{2}""") - seeded.forEach { domain -> - val reason = policyRepository.findById(domain).orElseThrow().reason ?: return@forEach - assertFalse(datePattern.containsMatchIn(reason), "$domain 사유에 날짜가 남아 있다: $reason") - } - } - - @Test - fun `허가 없이 HEADLESS_FIRST 를 지정하면 거절되고 행이 만들어지지 않는다`() { - // default-deny 의 실제 조작 지점. 추가 폼은 허가를 켤 수 없으므로(허가는 상세의 몫) 여기서 직행을 고르면 - // 저장 자체가 막혀야 한다 — 막히지 않으면 허가 없는 도메인이 브라우저로 열린다. - val mockMvc = mockMvc() - val domain = "deny-${UUID.randomUUID()}.example.com" - try { - mockMvc - .perform( - post("/admin/extraction-policies") - .with(csrf()) - .param("domain", domain) - .param("route", "HEADLESS_FIRST") - .param("reason", "no permission yet"), - ).andExpect(status().isOk) // 리다이렉트가 아니라 에러를 실은 보드 재표시 - - assertTrue(policyRepository.findById(domain).isEmpty, "허가 없는 HEADLESS_FIRST 는 저장되면 안 된다") - } finally { - policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() - } - } - @Test - fun `상세에서 허가를 켜면 근거·허가 시각이 원장에 남고 HEADLESS_FIRST 로 저장된다`() { - // "메일 받아 화이트리스트에 추가"의 실제 경로. 허가와 정책이 한 번의 저장으로 함께 확정돼야 - // "허가 없는 HEADLESS_FIRST" 같은 중간 상태를 지나지 않는다. - val mockMvc = mockMvc() - val domain = "granted-${UUID.randomUUID()}.example.com" - val permissionRef = "PERMIT-${UUID.randomUUID()}" - try { - policyRepository.save( - ExtractionPlatformPolicyEntity(domain = domain, route = ExtractionRoute.SUPPORTED.name, reason = null), - ) - routingPolicy.reload() - mockMvc - .perform( - post("/admin/extraction-policies/$domain") - .with(csrf()) - .param("route", "HEADLESS_FIRST") - .param("reason", "static fetch blocked") - .param("headlessAllowed", "true") - .param("permissionRef", permissionRef), - ).andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("/admin/extraction-policies?updated")) - val saved = policyRepository.findById(domain).orElseThrow() - assertTrue(saved.headlessAllowed) - assertEquals(permissionRef, saved.permissionRef) - assertNotNull(saved.permissionGrantedAt, "허가를 켠 시각이 원장에 남아야 한다") - // afterCommit reload 로 배포 없이 곧바로 추출 요청의 허가 판정에 반영된다. - assertTrue(routingPolicy.headlessAllowedOf(ProductLink.parse("https://$domain/p"))) - - // 상세 화면이 근거를 다시 보여준다(다음 사람이 "왜 열려 있나"를 되짚는 자리). - assertContains(html(mockMvc, "/admin/extraction-policies/$domain"), permissionRef) - } finally { - policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() - } - } - @Test - fun `허가를 끄면 그 도메인은 다시 헤드리스 불가가 되고 허가 시각도 비워진다`() { - val mockMvc = mockMvc() - val domain = "revoked-${UUID.randomUUID()}.example.com" - try { - policyRepository.save( - ExtractionPlatformPolicyEntity( - domain = domain, - route = ExtractionRoute.HEADLESS_FIRST.name, - reason = null, - headlessAllowed = true, - permissionRef = "old permission", - ), - ) - routingPolicy.reload() - - // 허가를 끄면 HEADLESS_FIRST 는 유지될 수 없다 — 정책도 함께 내려야 저장된다(같은 폼이라 한 번에 끝난다). - mockMvc - .perform( - post("/admin/extraction-policies/$domain") - .with(csrf()) - .param("route", "SUPPORTED") - .param("reason", "permission revoked"), - ).andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("/admin/extraction-policies?updated")) - - val saved = policyRepository.findById(domain).orElseThrow() - assertFalse(saved.headlessAllowed) - assertNull(saved.permissionGrantedAt) - assertFalse(routingPolicy.headlessAllowedOf(ProductLink.parse("https://$domain/p"))) - } finally { - policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() - } - } - - @Test - fun `근거 없이 허가만 켜면 거절된다 - 근거 없는 허가는 원장 구실을 못 한다`() { - val mockMvc = mockMvc() - val domain = "noref-${UUID.randomUUID()}.example.com" - try { - policyRepository.save( - ExtractionPlatformPolicyEntity(domain = domain, route = ExtractionRoute.SUPPORTED.name, reason = null), - ) - routingPolicy.reload() - - mockMvc - .perform( - post("/admin/extraction-policies/$domain") - .with(csrf()) - .param("route", "SUPPORTED") - .param("headlessAllowed", "true") - .param("permissionRef", " "), - ).andExpect(status().isOk) // 에러를 실은 상세 재표시 - - assertFalse(policyRepository.findById(domain).orElseThrow().headlessAllowed) - } finally { - policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() - } - } @Test fun `정책이 없는 도메인의 상세는 보드로 리다이렉트된다`() { @@ -354,17 +223,17 @@ class AdminExtractionPolicyIntegrationTest : IntegrationTestSupport() { } @Test - fun `이 서버가 모르는 route 행도 보드에 노출되고 상세에서 지울 수 있다`() { + fun `이 서버가 모르는 정책 값 행도 보드에 노출되고 상세에서 지울 수 있다`() { // 신버전이 만든 정책을 구버전으로 롤백하면 생긴다(DB 는 forward-only). 라우팅은 tolerant 하게 기본 체인으로 // 떨어지지만, 화면이 이 행을 어느 열에도 안 넣으면 백오피스에서 보이지도 지워지지도 않는 유령 행이 된다. val mockMvc = mockMvc() val domain = "unknown-${UUID.randomUUID()}.example.com" - val futureRoute = "FUTURE_ROUTE" + val futureRoute = "FUTURE_ACCESS" try { - policyRepository.save(ExtractionPlatformPolicyEntity(domain = domain, route = futureRoute, reason = null)) - routingPolicy.reload() + policyRepository.save(DomainAccessPolicyEntity(domain = domain, access = futureRoute, reason = null)) + accessPolicy.reload() - assertNull(routingPolicy.routeOf(ProductLink.parse("https://$domain/p")), "모르는 route 는 기본 체인으로 떨어진다") + assertNull(accessPolicy.accessOf(ProductLink.parse("https://$domain/p")), "모르는 route 는 기본 체인으로 떨어진다") val board = html(mockMvc, "/admin/extraction-policies") assertContains(board, domain) @@ -380,7 +249,7 @@ class AdminExtractionPolicyIntegrationTest : IntegrationTestSupport() { assertTrue(policyRepository.findById(domain).isEmpty) } finally { policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() + accessPolicy.reload() } } } diff --git a/src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntityTest.kt b/src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntityTest.kt new file mode 100644 index 000000000..665245e9b --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntityTest.kt @@ -0,0 +1,76 @@ +package com.depromeet.piki.product.routing + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +// 엔티티 불변식 — 어떤 경로가 만들든(백오피스·테스트·미래의 배치) 같은 결과가 나오게 도메인이 자기방어하는 층. +// 정상 흐름에선 입력 경계가 먼저 사용자 문구로 거르므로 여기 닿으면 그 경계가 검증을 빠뜨린 코드 버그다. +class DomainAccessPolicyEntityTest { + @Test + fun `차단 정책은 근거 없이도 만들어진다 - 차단은 우리 관측이라 상대의 허락이 필요 없다`() { + val policy = + DomainAccessPolicyEntity( + domain = "coupang.com", + access = DomainAccess.BLOCKED.name, + reason = "403 봇 차단", + ) + + assertEquals(DomainAccess.BLOCKED.name, policy.access) + assertNull(policy.permissionRef) + } + + @Test + fun `근거 없이 허락 정책 행은 만들 수 없다`() { + // 이 값은 우회 수단을 여는 값이라, 근거 없이 켜지면 원장이 "왜 열려 있나"에 답하지 못한다. + assertFailsWith { + DomainAccessPolicyEntity( + domain = "example.com", + access = DomainAccess.ALLOWED.name, + reason = "제휴 완료", + ) + } + } + + @Test + fun `근거를 남기면 허락 정책 행이 만들어진다`() { + val policy = + DomainAccessPolicyEntity( + domain = "example.com", + access = DomainAccess.ALLOWED.name, + reason = "제휴 담당자 회신", + permissionRef = "partner mail thread", + ) + + assertEquals(DomainAccess.ALLOWED.name, policy.access) + assertEquals("partner mail thread", policy.permissionRef) + } + + @Test + fun `공백뿐인 근거는 근거로 치지 않는다`() { + assertFailsWith { + DomainAccessPolicyEntity( + domain = "example.com", + access = DomainAccess.ALLOWED.name, + reason = null, + permissionRef = " ", + ) + } + } + + @Test + fun `도메인은 정규형이어야 한다`() { + assertFailsWith { + DomainAccessPolicyEntity(domain = "Coupang.com.", access = DomainAccess.BLOCKED.name, reason = null) + } + } + + @Test + fun `이 바이너리가 모르는 값도 행으로는 만들어진다 - 읽는 쪽이 tolerant 하게 진다`() { + // @Enumerated 로 두면 모르는 값 한 행이 findAll 하이드레이션을 깨 부팅이 죽는다(구버전 롤백 함정). + val policy = DomainAccessPolicyEntity(domain = "example.com", access = "FUTURE_VALUE", reason = null) + + assertEquals("FUTURE_VALUE", policy.access) + } +} diff --git a/src/test/kotlin/com/depromeet/piki/product/routing/ExtractionRoutingPolicyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyIntegrationTest.kt similarity index 57% rename from src/test/kotlin/com/depromeet/piki/product/routing/ExtractionRoutingPolicyIntegrationTest.kt rename to src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyIntegrationTest.kt index 49793fe53..eec401667 100644 --- a/src/test/kotlin/com/depromeet/piki/product/routing/ExtractionRoutingPolicyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyIntegrationTest.kt @@ -28,7 +28,7 @@ import kotlin.test.assertTrue // 추출 라우팅 정책(#9 디스패처)이 DB + 캐시 reload 로 배포 없이 등록 판정을 바꾸는지 검증한다. // @Transactional 자동 롤백을 쓰지 않는다 — 정책 캐시(@Volatile)는 롤백으로 되돌아가지 않아 다른 테스트로 누수되므로, // 각 테스트가 자기 행을 명시적으로 정리하고 reload() 로 캐시를 시드 상태로 복원한다(finally). -class ExtractionRoutingPolicyIntegrationTest : IntegrationTestSupport() { +class DomainAccessPolicyIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var webApplicationContext: WebApplicationContext @@ -39,10 +39,10 @@ class ExtractionRoutingPolicyIntegrationTest : IntegrationTestSupport() { private lateinit var jwtProvider: JwtProvider @Autowired - private lateinit var policyRepository: ExtractionPlatformPolicyJpaRepository + private lateinit var policyRepository: DomainAccessPolicyJpaRepository @Autowired - private lateinit var routingPolicy: DbExtractionRoutingPolicy + private lateinit var accessPolicy: DbDomainAccessPolicy @Autowired private lateinit var stubProductLinkExtractor: StubProductLinkExtractor @@ -54,69 +54,30 @@ class ExtractionRoutingPolicyIntegrationTest : IntegrationTestSupport() { .build() @Test - fun `시드된 미지원 플랫폼 7곳이 실제 정책 경로(마이그레이션 시드 → 캐시)로 전부 UNSUPPORTED 로 판정된다`() { - // 시드 SQL 의 도메인 오타·누락은 컴파일·단위 테스트로 안 드러난다 — 실제 출처(Flyway 시드)를 여기서 고정해 - // 차단이 조용히 풀리는 회귀를 CI 빨간불로 만든다. 서브도메인 포함(www.) 매칭도 함께 본다. - val seeded = listOf("kream.co.kr", "coupang.com", "naver.com", "naver.me", "oliveyoung.co.kr", "oy.run", "a-bly.com") - seeded.forEach { domain -> - assertEquals( - ExtractionRoute.UNSUPPORTED, - routingPolicy.routeOf(ProductLink.parse("https://www.$domain/p/1")), - "$domain 시드 누락 또는 오타", - ) - } - } - - @Test - fun `시드된 미지원 플랫폼은 헤드리스 허가가 꺼진 채로 남는다 - 마이그레이션 기본값이 곧 default-deny`() { - // 허가 컬럼을 더한 마이그레이션이 기존 행을 건드리지 않았는지 실제 DB 로 확인한다. 기본값이 TRUE 로 - // 잘못 들어가면 차단 플랫폼 전부가 조용히 브라우저로 열리는 셈이 된다 — 그 회귀를 CI 빨간불로 만든다. - val seeded = - listOf( - "kream.co.kr", - "coupang.com", - "naver.com", - "naver.me", - "oliveyoung.co.kr", - "oy.run", - "a-bly.com", - ) - seeded.forEach { domain -> - assertFalse(policyRepository.findById(domain).orElseThrow().headlessAllowed, "$domain 의 허가가 켜져 있다") - assertFalse(routingPolicy.headlessAllowedOf(ProductLink.parse("https://www.$domain/p/1"))) - } - } - - @Test - fun `정책 행이 없는 도메인은 헤드리스 불가이고, 허가를 켠 행만 가능으로 바뀐다`() { - // "행 없음 = 기본 = 불가" 규약과 그 반대 축(허가를 켜면 즉시 반영)을 한 자리에서 고정한다. + fun `정책 행이 없으면 허락도 차단도 아니다 - 행을 넣으면 즉시 반영된다`() { + // "행 없음 = 기본" 규약과 그 반대 축(값을 넣으면 캐시 reload 로 즉시 반영)을 한 자리에서 고정한다. val domain = "permission-${UUID.randomUUID()}.example.com" val link = ProductLink.parse("https://shop.$domain/p/1") try { - assertFalse(routingPolicy.headlessAllowedOf(link), "정책 행이 없는 도메인은 허가 없음이어야 한다") + assertFalse(accessPolicy.authorizedFor(link), "행이 없으면 허락이 아니다") + assertFalse(accessPolicy.blocked(link), "행이 없으면 차단도 아니다") - // 허가 없이 정책만 있는 행도 여전히 불가 — 행의 존재가 허가를 뜻하지 않는다. policyRepository.save( - ExtractionPlatformPolicyEntity(domain = domain, route = ExtractionRoute.SUPPORTED.name, reason = null), - ) - routingPolicy.reload() - assertFalse(routingPolicy.headlessAllowedOf(link)) - - policyRepository.save( - ExtractionPlatformPolicyEntity( + DomainAccessPolicyEntity( domain = domain, - route = ExtractionRoute.HEADLESS_FIRST.name, + access = DomainAccess.ALLOWED.name, reason = null, - headlessAllowed = true, permissionRef = "test permission", ), ) - routingPolicy.reload() - assertTrue(routingPolicy.headlessAllowedOf(link), "허가를 켠 행은 서브도메인까지 허가로 판정돼야 한다") - assertEquals(ExtractionRoute.HEADLESS_FIRST, routingPolicy.routeOf(link)) + accessPolicy.reload() + + assertTrue(accessPolicy.authorizedFor(link), "허락 행은 서브도메인까지 허락으로 판정돼야 한다") + assertFalse(accessPolicy.blocked(link), "허락은 차단이 아니다") + assertEquals(DomainAccess.ALLOWED, accessPolicy.accessOf(link)) } finally { policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() + accessPolicy.reload() } } @@ -127,31 +88,30 @@ class ExtractionRoutingPolicyIntegrationTest : IntegrationTestSupport() { val parent = "overlap-${UUID.randomUUID()}.example.com" val sub = "m.$parent" try { - policyRepository.save(ExtractionPlatformPolicyEntity(domain = parent, route = ExtractionRoute.UNSUPPORTED.name, reason = null)) - // HEADLESS_FIRST 는 허가 없이는 만들 수 없다(엔티티 불변식) — 서브도메인만 열어 주는 시나리오라 허가도 함께 켠다. + policyRepository.save(DomainAccessPolicyEntity(domain = parent, access = DomainAccess.BLOCKED.name, reason = null)) + // ALLOWED 는 근거 없이는 만들 수 없다(엔티티 불변식) — 서브도메인만 열어 주는 시나리오다. policyRepository.save( - ExtractionPlatformPolicyEntity( + DomainAccessPolicyEntity( domain = sub, - route = ExtractionRoute.HEADLESS_FIRST.name, + access = DomainAccess.ALLOWED.name, reason = null, - headlessAllowed = true, permissionRef = "test permission", ), ) - routingPolicy.reload() + accessPolicy.reload() - assertEquals(ExtractionRoute.HEADLESS_FIRST, routingPolicy.routeOf(ProductLink.parse("https://$sub/p/1"))) - assertEquals(ExtractionRoute.UNSUPPORTED, routingPolicy.routeOf(ProductLink.parse("https://$parent/p/1"))) - assertEquals(ExtractionRoute.UNSUPPORTED, routingPolicy.routeOf(ProductLink.parse("https://www.$parent/p/1"))) + assertEquals(DomainAccess.ALLOWED, accessPolicy.accessOf(ProductLink.parse("https://$sub/p/1"))) + assertEquals(DomainAccess.BLOCKED, accessPolicy.accessOf(ProductLink.parse("https://$parent/p/1"))) + assertEquals(DomainAccess.BLOCKED, accessPolicy.accessOf(ProductLink.parse("https://www.$parent/p/1"))) } finally { policyRepository.findById(parent).ifPresent { policyRepository.delete(it) } policyRepository.findById(sub).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() + accessPolicy.reload() } } @Test - fun `UNSUPPORTED 정책을 추가하면 등록이 400 으로 거부되고, 삭제하면 같은 URL 이 다시 통과한다`() { + fun `BLOCKED 정책을 추가하면 등록이 400 으로 거부되고, 삭제하면 같은 URL 이 다시 통과한다`() { val mockMvc = mockMvc() val userId = UUID.randomUUID() insertMember(userId) @@ -161,8 +121,8 @@ class ExtractionRoutingPolicyIntegrationTest : IntegrationTestSupport() { stubProductLinkExtractor.build = { ProductSnapshot(link = it, name = "테스트 상품", price = 9_900) } try { // 정책 추가 + reload — 배포 없이 곧바로 등록이 거부된다(백오피스 저장 → afterCommit reload 와 같은 경로). - policyRepository.save(ExtractionPlatformPolicyEntity(domain = domain, route = ExtractionRoute.UNSUPPORTED.name, reason = "테스트")) - routingPolicy.reload() + policyRepository.save(DomainAccessPolicyEntity(domain = domain, access = DomainAccess.BLOCKED.name, reason = "테스트")) + accessPolicy.reload() mockMvc .perform( @@ -175,7 +135,7 @@ class ExtractionRoutingPolicyIntegrationTest : IntegrationTestSupport() { // 정책 삭제 + reload — 차단이 풀리면(봇 차단은 변동적) 행만 지워 되돌린다. 같은 URL 이 이제 등록된다. policyRepository.deleteById(domain) - routingPolicy.reload() + accessPolicy.reload() mockMvc .perform( @@ -186,37 +146,42 @@ class ExtractionRoutingPolicyIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isCreated) } finally { policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } // 400 경로에서 남았을 때만 - routingPolicy.reload() + accessPolicy.reload() cleanup(userId) } } @Test - fun `SUPPORTED 정책은 라우팅을 바꾸지 않아 등록이 그대로 통과한다`() { - // SUPPORTED 는 판정이 아니라 기록용 값이다("실측으로 잘 됨을 확인"). 소비처가 UNSUPPORTED·HEADLESS_FIRST 와의 - // 정확한 값 비교만 하므로 기본 체인을 타야 하는데, 누군가 verifyRegistrable 을 "정책 행이 있기만 하면 거절"로 - // 고치면 SUPPORTED 도메인의 등록이 조용히 막힌다 — 그 회귀를 여기서 잡는다. + fun `허락 정책은 등록을 막지 않는다 - 차단만 등록 경계를 막는다`() { + // 축이 하나로 합쳐지면서 "행이 있으면 뭔가 제한된다"는 오해가 생기기 쉽다. ALLOWED 는 오히려 더 여는 + // 값이라 등록 경계와 무관해야 하는데, 누군가 verifyRegistrable 을 "정책 행이 있기만 하면 거절"로 고치면 + // 허락받은 도메인의 등록이 조용히 막힌다 — 그 회귀를 여기서 잡는다. val mockMvc = mockMvc() val userId = UUID.randomUUID() insertMember(userId) - val domain = "supported-${UUID.randomUUID()}.example.com" + val domain = "allowed-${UUID.randomUUID()}.example.com" stubProductLinkExtractor.build = { ProductSnapshot(link = it, name = "테스트 상품", price = 9_900) } try { - policyRepository.save(ExtractionPlatformPolicyEntity(domain = domain, route = ExtractionRoute.SUPPORTED.name, reason = "실측 확인")) - routingPolicy.reload() - - assertEquals(ExtractionRoute.SUPPORTED, routingPolicy.routeOf(ProductLink.parse("https://$domain/p/1"))) + policyRepository.save( + DomainAccessPolicyEntity( + domain = domain, + access = DomainAccess.ALLOWED.name, + reason = null, + permissionRef = "test permission", + ), + ) + accessPolicy.reload() mockMvc .perform( post("/api/v1/wishlists") .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}") - .content("""{"url": "https://$domain/p/1"}"""), + .content("""{"url": "https://shop.$domain/p/1"}"""), ).andExpect(status().isCreated) } finally { policyRepository.findById(domain).ifPresent { policyRepository.delete(it) } - routingPolicy.reload() + accessPolicy.reload() cleanup(userId) } } diff --git a/src/test/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyEntityTest.kt b/src/test/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyEntityTest.kt deleted file mode 100644 index ab02cf288..000000000 --- a/src/test/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyEntityTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.depromeet.piki.product.routing - -import org.junit.jupiter.api.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNull - -// 정책 행의 불변식. 헤드리스 허가는 기본이 거부이고(default-deny), 허가 없는 브라우저 직행 행은 어느 경로로도 -// 만들 수 없어야 한다 — 정상 흐름에선 입력 경계(AdminExtractionPolicyService)가 사용자 문구로 먼저 거르므로 -// 여기 닿는 것은 그 경계를 빠뜨린 코드 버그다(그래서 require = 500). -class ExtractionPlatformPolicyEntityTest { - @Test - fun `헤드리스 허가는 지정하지 않으면 거부다 - 기본값이 곧 원장의 default-deny`() { - val policy = - ExtractionPlatformPolicyEntity( - domain = "example.com", - route = ExtractionRoute.SUPPORTED.name, - reason = null, - ) - - assertFalse(policy.headlessAllowed) - assertNull(policy.permissionRef) - assertNull(policy.permissionGrantedAt) - } - - @Test - fun `허가 없이 HEADLESS_FIRST 정책 행은 만들 수 없다`() { - assertFailsWith { - ExtractionPlatformPolicyEntity( - domain = "example.com", - route = ExtractionRoute.HEADLESS_FIRST.name, - reason = null, - headlessAllowed = false, - ) - } - } - - @Test - fun `허가를 켜면 HEADLESS_FIRST 정책 행이 만들어진다`() { - val policy = - ExtractionPlatformPolicyEntity( - domain = "example.com", - route = ExtractionRoute.HEADLESS_FIRST.name, - reason = "정적 fetch 전 UA 403", - headlessAllowed = true, - permissionRef = "partner mail thread", - ) - - assertEquals(ExtractionRoute.HEADLESS_FIRST.name, policy.route) - assertEquals("partner mail thread", policy.permissionRef) - } - - @Test - fun `허가 없이도 HEADLESS_FIRST 가 아닌 정책은 만들어진다 - 허가는 브라우저 직행에만 걸리는 조건이다`() { - // 허가를 요구하는 자리를 넓히면 차단(UNSUPPORTED) 기록조차 허가 없이는 못 남기게 된다. - ExtractionRoute.entries - .filter { it != ExtractionRoute.HEADLESS_FIRST } - .forEach { route -> - val policy = ExtractionPlatformPolicyEntity(domain = "example.com", route = route.name, reason = null) - assertEquals(route.name, policy.route) - } - } -} diff --git a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractorTest.kt b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractorTest.kt index 51d6f7e36..3e27540f0 100644 --- a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractorTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractorTest.kt @@ -5,8 +5,9 @@ import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.item.service.AsyncItemParsingWorker import com.depromeet.piki.item.service.ItemParsingMetrics 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.DomainAccess +import com.depromeet.piki.product.routing.DomainAccessPolicy import com.depromeet.piki.product.service.ProductSnapshotException import org.junit.jupiter.api.Test import org.springframework.http.HttpMethod @@ -34,15 +35,14 @@ import kotlin.test.assertTrue class HttpProductLinkExtractorTest { private val link = ProductLink.parse("https://shop.example.com/p/1") - // headlessAllowed 의 기본을 false 로 둔다 — 허가는 정책 행에 명시적으로 켜야만 생기는 사실이라(default-deny), - // Fake 도 그 전제를 지켜야 "허가를 안 켰는데 true 로 나가는" 회귀를 잡을 수 있다. - private class FakeRoutingPolicy( - private val route: ExtractionRoute? = null, - private val headlessAllowed: Boolean = false, - ) : ExtractionRoutingPolicy { - override fun routeOf(link: ProductLink): ExtractionRoute? = route + // 기본을 "정책 행 없음"으로 둔다 — 허락은 명시적으로 켜야만 생기는 사실이라(default-deny), Fake 도 그 + // 전제를 지켜야 "허락을 안 켰는데 true 로 나가는" 회귀를 잡을 수 있다. + private class FakeAccessPolicy( + private val access: DomainAccess? = null, + ) : DomainAccessPolicy { + override fun accessOf(link: ProductLink): DomainAccess? = access - override fun headlessAllowedOf(link: ProductLink): Boolean = headlessAllowed + override fun authorizedFor(link: ProductLink): Boolean = access == DomainAccess.ALLOWED } // 지정한 축에만 값을 준다 — target 을 무시하고 늘 같은 값을 돌려주면, 링크 추출기가 IMAGE 축을 읽는 @@ -55,8 +55,7 @@ class HttpProductLinkExtractorTest { } private fun extractorWith( - route: ExtractionRoute? = null, - headlessAllowed: Boolean = false, + access: DomainAccess? = null, model: String? = null, server: (MockRestServiceServer) -> Unit, ): HttpProductLinkExtractor { @@ -65,13 +64,13 @@ class HttpProductLinkExtractorTest { server(mockServer) return HttpProductLinkExtractor( builder.build(), - FakeRoutingPolicy(route, headlessAllowed), + FakeAccessPolicy(access), FakeModelSettings(ExtractionTarget.LINK, model), ) } @Test - fun `200 응답의 추출 결과를 ProductSnapshot 으로 매핑한다 - 정책 없는 도메인은 headlessFirst=false 로 보낸다`() { + fun `200 응답의 추출 결과를 ProductSnapshot 으로 매핑한다 - 정책 없는 도메인은 authorized=false 로 보낸다`() { val extractor = extractorWith { server -> server @@ -79,7 +78,7 @@ class HttpProductLinkExtractorTest { .andExpect(method(HttpMethod.POST)) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.url").value("https://shop.example.com/p/1")) - .andExpect(jsonPath("$.headlessFirst").value(false)) + .andExpect(jsonPath("$.authorized").value(false)) .andRespond( withSuccess( """{"name":"나이키","imageUrl":"https://cdn.example.com/i.png","currentPrice":99000,"currency":"KRW"}""", @@ -98,14 +97,13 @@ class HttpProductLinkExtractorTest { } @Test - fun `라우팅 정책이 HEADLESS_FIRST 이고 허가된 도메인은 headlessFirst=true 힌트를 실어 보낸다`() { - // 정책(DB·백오피스)의 단일 진실은 이쪽 — extractor 는 이 힌트로 plain 을 건너뛰고 브라우저 직행한다(계약 §2). - // headlessFirst 는 route 가 HEADLESS_FIRST 이면서 허가된 도메인에만 켜지므로 허가(headlessAllowed=true)를 함께 준다. + fun `허락받은 도메인은 authorized=true 를 실어 보낸다`() { + // 허락 판정의 원장은 이쪽(DB·백오피스) — extractor·renderer 는 이 값으로 우회 수단을 열 뿐 스스로 알지 않는다. val extractor = - extractorWith(route = ExtractionRoute.HEADLESS_FIRST, headlessAllowed = true) { server -> + extractorWith(access = DomainAccess.ALLOWED) { server -> server .expect(requestTo("http://extractor.test/internal/extractions/link")) - .andExpect(jsonPath("$.headlessFirst").value(true)) + .andExpect(jsonPath("$.authorized").value(true)) .andRespond( withSuccess( """{"name":"크림 상품","imageUrl":"https://cdn.example.com/k.png","currentPrice":209000,"currency":"KRW"}""", @@ -120,14 +118,13 @@ class HttpProductLinkExtractorTest { } @Test - fun `라우팅 정책이 UNSUPPORTED 여도 힌트는 headlessFirst=false 다 - 직행 힌트는 HEADLESS_FIRST 한정`() { - // UNSUPPORTED 는 등록 경계(verifyRegistrable)가 막는 정책이라 여기 닿는 건 기존 저장 행의 재파싱 등 — - // 그 경우에도 브라우저 직행으로 격상하지 않고 기본 체인에 맡긴다. + fun `정책 행이 없으면 authorized=false 로 나간다`() { + // 대부분의 도메인이 이 경우다. 허락은 명시적으로 켜야만 생기는 사실이라 기본은 거부다. val extractor = - extractorWith(route = ExtractionRoute.UNSUPPORTED) { server -> + extractorWith() { server -> server .expect(requestTo("http://extractor.test/internal/extractions/link")) - .andExpect(jsonPath("$.headlessFirst").value(false)) + .andExpect(jsonPath("$.authorized").value(false)) .andRespond( withSuccess( """{"name":"나이키","imageUrl":"https://cdn.example.com/i.png","currentPrice":99000,"currency":"KRW"}""", @@ -139,35 +136,17 @@ class HttpProductLinkExtractorTest { extractor.extract(link) } - @Test - fun `정책 행이 없는 도메인은 headlessAllowed=false 로 보낸다 - 허가 원장의 기본은 거부다`() { - // 대부분의 도메인이 이 경로다. 여기가 true 로 새면 "허가받은 곳만 브라우저로 연다"는 약속이 통째로 무너진다. - val extractor = - extractorWith { server -> - server - .expect(requestTo("http://extractor.test/internal/extractions/link")) - .andExpect(jsonPath("$.headlessAllowed").value(false)) - .andRespond( - withSuccess( - """{"name":"나이키","imageUrl":"https://cdn.example.com/i.png","currentPrice":99000,"currency":"KRW"}""", - MediaType.APPLICATION_JSON, - ), - ) - } - - assertEquals("나이키", extractor.extract(link).name) - } @Test - fun `허가받은 도메인은 headlessAllowed=true 를 실어 보낸다`() { + fun `허락받은 도메인은 authorized=true 를 실어 보낸다 - 원장은 이쪽에 있다`() { // 허가 원장(extraction_platform_policies.headless_allowed)의 단일 진실은 이쪽 DB 이고, 무상태인 extractor 는 // 요청 단위로만 받는다 — 이 필드가 빠지면 저쪽은 허가 여부를 알 길이 없다. val extractor = - extractorWith(route = ExtractionRoute.HEADLESS_FIRST, headlessAllowed = true) { server -> + extractorWith(access = DomainAccess.ALLOWED) { server -> server .expect(requestTo("http://extractor.test/internal/extractions/link")) - .andExpect(jsonPath("$.headlessAllowed").value(true)) - .andExpect(jsonPath("$.headlessFirst").value(true)) + .andExpect(jsonPath("$.authorized").value(true)) + .andExpect(jsonPath("$.authorized").value(true)) .andRespond( withSuccess( """{"name":"허가 상품","imageUrl":"https://cdn.example.com/k.png","currentPrice":209000,"currency":"KRW"}""", @@ -180,46 +159,15 @@ class HttpProductLinkExtractorTest { } @Test - fun `route 가 HEADLESS_FIRST 여도 허가가 없으면 headlessFirst=false 로 보낸다 - 모순 요청을 막는 게이트`() { - // 허가 없이 HEADLESS_FIRST 로 남은 행(이 기능 이전 데이터·구버전이 만든 행)을 만나도 core 가 스스로 - // default-deny 를 지킨다 — "브라우저 직행(headlessFirst)"과 "허가 없음(headlessAllowed=false)"이 함께 - // 나가는 모순 요청을 원천 차단한다. extractor 가 먼저 배포되는 구간의 안전이 이 게이트에 달려 있다. - val extractor = - extractorWith(route = ExtractionRoute.HEADLESS_FIRST, headlessAllowed = false) { server -> - server - .expect(requestTo("http://extractor.test/internal/extractions/link")) - .andExpect(jsonPath("$.headlessFirst").value(false)) - .andExpect(jsonPath("$.headlessAllowed").value(false)) - .andRespond( - withSuccess( - """{"name":"나이키","imageUrl":"https://cdn.example.com/i.png","currentPrice":99000,"currency":"KRW"}""", - MediaType.APPLICATION_JSON, - ), - ) - } + fun `차단 도메인은 요청 자체를 보내지 않는다`() { + // 등록 경계가 새 등록을 막지만 그것만으로는 이미 담긴 아이템의 재파싱이 그대로 나간다 — extractor 로 나가는 + // 유일한 출구인 여기서 막아야 "거부 의사를 확인한 곳에 다시 두드리지 않는다"가 성립한다. + // MockRestServiceServer 에 아무 기대도 걸지 않았으므로, 요청이 나가면 그 자체로 실패한다. + val extractor = extractorWith(access = DomainAccess.BLOCKED) { } - extractor.extract(link) + assertFailsWith { extractor.extract(link) } } - @Test - fun `허가받지 않은 도메인은 정책이 있어도 headlessAllowed=false 다`() { - // 정책 행이 있다는 사실만으로 허가가 되지 않는다. 허가 컬럼을 켜기 전(또는 이 기능 이전에 만들어진 행)에는 - // 정책이 무엇이든 브라우저를 열 수 없다. - val extractor = - extractorWith(route = ExtractionRoute.SUPPORTED) { server -> - server - .expect(requestTo("http://extractor.test/internal/extractions/link")) - .andExpect(jsonPath("$.headlessAllowed").value(false)) - .andRespond( - withSuccess( - """{"name":"나이키","imageUrl":"https://cdn.example.com/i.png","currentPrice":99000,"currency":"KRW"}""", - MediaType.APPLICATION_JSON, - ), - ) - } - - extractor.extract(link) - } @Test fun `200 이어도 경계 정규화를 거친다 - non-https imageUrl 은 null 로, 소문자 currency 는 ISO 정규형으로`() { diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index 014ab4cad..33a209f3b 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -73,6 +73,10 @@ class TournamentIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var objectMapper: ObjectMapper + @Autowired private lateinit var accessPolicyRepository: com.depromeet.piki.product.routing.DomainAccessPolicyJpaRepository + + @Autowired private lateinit var accessPolicy: com.depromeet.piki.product.routing.DbDomainAccessPolicy + @Autowired private lateinit var tournamentItemJpaRepository: TournamentItemJpaRepository @Autowired private lateinit var tournamentJpaRepository: TournamentJpaRepository @@ -2244,11 +2248,20 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `POST tournaments-id-items-link 에서 미지원 플랫폼(KREAM) URL 이면 400 을 반환한다`() { + fun `POST tournaments-id-items-link 에서 차단 도메인 URL 이면 400 을 반환한다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - // 미지원 플랫폼(봇 차단으로 fetch 불가)은 등록 입력 시점에 동기 400 으로 막는다 — 위시 등록과 같은 메커니즘. + // 차단 도메인은 등록 입력 시점에 동기 400 으로 막는다 — 위시 등록과 같은 메커니즘. + // 정책 테이블은 비어서 시작하므로(판단하지 않은 것을 미리 채우지 않는다) 이 테스트가 자기 행을 만든다. + accessPolicyRepository.save( + com.depromeet.piki.product.routing.DomainAccessPolicyEntity( + domain = "kream.co.kr", + access = com.depromeet.piki.product.routing.DomainAccess.BLOCKED.name, + reason = "테스트: 차단 도메인", + ), + ) + accessPolicy.reload() mockMvc .perform( post("/api/v1/tournaments/$tournamentId/items/link") @@ -2262,7 +2275,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { // 등록 입력 경계에서 차단되므로 tournament item 이 생성되면 안 된다(검증 위치가 뒤로 밀려 일부라도 영속화되는 회귀 방지). assertTrue( tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).isEmpty(), - "미지원 플랫폼은 등록 입력 경계에서 차단되어 tournament item 이 생성되면 안 됩니다.", + "차단 도메인은 등록 입력 경계에서 걸러져 tournament item 이 생성되면 안 됩니다.", ) } diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt index 045074177..763faf75f 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt @@ -58,6 +58,12 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var webApplicationContext: WebApplicationContext + @Autowired + private lateinit var accessPolicyRepository: com.depromeet.piki.product.routing.DomainAccessPolicyJpaRepository + + @Autowired + private lateinit var accessPolicy: com.depromeet.piki.product.routing.DbDomainAccessPolicy + @Autowired private lateinit var objectMapper: ObjectMapper @@ -605,12 +611,21 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { } @Test - fun `미지원 플랫폼(KREAM) URL 을 등록하면 등록 시점에 400 으로 거부되고 위시가 생기지 않는다`() { + fun `차단 도메인 URL 을 등록하면 등록 시점에 400 으로 거부되고 위시가 생기지 않는다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { - // 미지원 플랫폼은 비동기 파싱(FAILED)이 아니라 등록 입력 시점에 동기 400 으로 막는다 — 담기 전에 빠르게 안내한다. + // 차단 도메인은 비동기 파싱(FAILED)이 아니라 등록 입력 시점에 동기 400 으로 막는다 — 담기 전에 빠르게 안내한다. + // 정책 테이블은 비어서 시작하므로(판단하지 않은 것을 미리 채우지 않는다) 이 테스트가 자기 행을 만든다. + accessPolicyRepository.save( + com.depromeet.piki.product.routing.DomainAccessPolicyEntity( + domain = "kream.co.kr", + access = com.depromeet.piki.product.routing.DomainAccess.BLOCKED.name, + reason = "테스트: 차단 도메인", + ), + ) + accessPolicy.reload() val body = objectMapper.writeValueAsString(mapOf("url" to "https://kream.co.kr/products/950123")) mockMvc From 7b6dac5663c60f996565f6606e771008a907741b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:09:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EC=A0=95=EC=B1=85=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EC=88=98=EB=8B=A8=20=EC=96=B4=ED=9C=98?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core 가 아는 것은 "이 도메인이 허락을 받았는가" 하나여야 하는데, 주석과 백오피스 문구가 "우회 수단(지문 보정·프록시)", "정적 fetch → 안 되면 브라우저" 처럼 렌더 모듈의 구현을 서술하고 있었다. 수단이 바뀌면 core 쪽 서술만 조용히 낡으므로 "기본 수단 / 적극적인 수단" 으로 올리고, 무엇을 쓸지는 추출 모듈이 정한다는 사실까지만 남긴다 - 타입·필드·스키마·계약에는 원래 수단 어휘가 없었다. 이번에 걷어낸 것은 전부 주석과 운영자 대면 문구다 - 백오피스 정책 설명은 문장을 개조식으로 풀었다 — 한 항목이 한 사실을 져야 갈래끼리 세로로 훑어 비교된다 - 축 교체 후 남아 있던 잔재를 함께 정리했다: 삭제된 클래스를 가리키던 주석(ExtractionRoutingPolicy · ExtractionPlatformPolicyEntity), 옛 정책 값 이름(UNSUPPORTED 정책 후보 → BLOCKED), 화면 제목의 "추출 라우팅 정책" → "도메인 접근 정책" - 필드명 extractionRoutingPolicy 를 accessPolicy 로 바꿨다. 타입은 이미 DomainAccessPolicy 인데 이름만 옛 축을 가리키고 있었다 - 3열 시절의 죽은 CSS(rowspan 병합 규칙, 제거된 허가 체크박스의 .check·.badge.allowed·.badge.denied)를 지웠다 - 코드와 어긋나 있던 주석도 정정했다. 추가 폼 기본값이 BLOCKED 인데 주석은 "기본값이 SUPPORTED 인 이유" 를 설명하고 있었다 — 값이 둘뿐이라 "아무것도 안 바꾸는 값" 이 없고, 되돌리기 쉬운 쪽을 기본에 둔다는 근거로 다시 썼다 --- .../piki/admin/audit/AdminAuditAction.kt | 2 +- .../AdminExtractionModelController.kt | 2 +- .../AdminExtractionPolicyController.kt | 20 +++--- .../AdminExtractionPolicyService.kt | 2 +- .../common/ratelimit/ItemQuotaProperties.kt | 8 +-- .../common/ratelimit/ItemQuotaSettings.kt | 2 +- .../piki/item/service/ItemParsingMetrics.kt | 2 +- .../piki/product/domain/CanonicalLink.kt | 2 +- .../piki/product/domain/ProductLink.kt | 2 +- .../product/domain/ProductLinkErrorCode.kt | 2 +- .../piki/product/routing/DomainAccess.kt | 7 ++- .../product/routing/DomainAccessPolicy.kt | 6 +- .../routing/DomainAccessPolicyEntity.kt | 4 +- .../service/ExtractionFailureBucket.kt | 2 +- .../service/remote/ExtractionModelEntity.kt | 2 +- .../service/remote/ExtractionModelSettings.kt | 8 +-- .../remote/HttpProductLinkExtractor.kt | 6 +- .../remote/ProductExtractorErrorCode.kt | 2 +- .../remote/ProductExtractorException.kt | 2 +- .../remote/RemoteExtractionContract.kt | 2 +- .../product/source/SourcePlatformEntity.kt | 2 +- .../product/source/SourcePlatformResolver.kt | 6 +- .../controller/TournamentItemApiExamples.kt | 2 +- .../service/TournamentItemService.kt | 4 +- .../controller/WishlistApiExamples.kt | 2 +- .../piki/wishlist/service/WishlistService.kt | 6 +- src/main/resources/application.yml | 4 +- ...extraction_policies_with_domain_access.sql | 8 +-- .../templates/admin/extraction-policies.html | 61 +++++++++++-------- .../admin/extraction-policy-detail.html | 32 +++++----- src/main/resources/templates/admin/index.html | 4 +- .../AdminExtractionPolicyIntegrationTest.kt | 4 +- .../item/domain/ItemSnapshotSourceTest.kt | 2 +- .../piki/product/domain/ProductLinkTest.kt | 4 +- .../routing/DomainAccessPolicyEntityTest.kt | 2 +- .../DomainAccessPolicyIntegrationTest.kt | 2 +- .../remote/HttpProductLinkExtractorTest.kt | 4 +- 37 files changed, 124 insertions(+), 110 deletions(-) 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 9130e195d..9a6b27e8d 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyController.kt @@ -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)). // 파괴적 액션(삭제)은 보드에 두지 않는다. 상세로 들어와 도메인·정책·사유를 확인한 뒤 실행한다 — @@ -34,7 +34,7 @@ class AdminExtractionPolicyController( model: Model, ): String = boardView(access, model, guideOpen = !guide.isNullOrBlank()) - // 추가 폼은 헤드리스 허가를 켜지 않는다(save 의 기본값 = 거부). 허가는 "메일로 받아 원장에 남기는" 조작이라 + // 추가 폼은 허락 근거를 받지 않는다(save 의 기본값 = null). 허락은 "메일로 받아 원장에 남기는" 조작이라 // 근거 입력·현재 상태 확인이 있는 상세 화면의 몫이다 — 목록의 한 줄짜리 폼에서 지나가듯 켤 일이 아니다. // 그래서 여기서 ALLOWED 를 고르면 근거 필수 가드에 걸려 "상세에서 근거를 남겨라"는 안내가 뜬다. @PostMapping @@ -68,10 +68,9 @@ class AdminExtractionPolicyController( "redirect:/admin/extraction-policies?missing" } - // 상세의 정책·사유·헤드리스 허가 수정. save 가 upsert 라 추가 폼과 같은 경로를 탄다 (도메인은 PK 라 상세에서 - // 바꾸지 않는다). 정책과 사유를 한 폼으로 받는 이유: 정책이 바뀌는 순간이 곧 근거가 새로 필요한 순간이라, 둘이 - // 따로 저장되면 "403 봇 차단" 사유가 SUPPORTED 행에 남는 식으로 근거가 정책과 어긋난다. 허가도 같은 폼에 둔다 — - // + // 상세의 정책·사유·허락 근거 수정. save 가 upsert 라 추가 폼과 같은 경로를 탄다 (도메인은 PK 라 상세에서 + // 바꾸지 않는다). 셋을 한 폼으로 받는 이유: 정책이 바뀌는 순간이 곧 근거가 새로 필요한 순간이라, 따로 + // 저장되면 "403 봇 차단" 사유가 ALLOWED 행에 남는 식으로 근거가 정책과 어긋난다. @PostMapping("/{domain}") fun update( @PathVariable domain: String, @@ -118,8 +117,9 @@ class AdminExtractionPolicyController( // 보드 화면의 모델 채우기 단일 지점 — 정상 목록과 두 에러 재표시 경로가 공유한다 // (한쪽만 갱신돼 에러 화면에서 모델이 비는 함정 방지). - // selectedAccess 기본값이 SUPPORTED 인 이유: 추가 폼의 기본 선택이 곧 오조작 시 저장되는 값이라, - // 라우팅을 바꾸지 않는 값(기록용)을 기본에 둔다. UNSUPPORTED 가 기본이면 실수 한 번이 등록 차단이 된다. + // selectedAccess 기본값이 BLOCKED 인 이유: 값이 둘뿐이라 "아무것도 안 바꾸는 값"이 없다. ALLOWED 를 기본에 + // 두면 도메인만 치고 엔터한 오조작이 허락으로 저장될 수 있으므로, 되돌리기 쉬운 쪽(행 삭제 = 즉시 등록 재개)을 + // 기본에 둔다. ALLOWED 는 근거 필수 가드가 한 번 더 막아 어느 쪽으로도 지나가듯 켜지지 않는다. // guideOpen 은 모델로 넘긴다. 템플릿의 th:attr 안에서는 요청 파라미터(param) 접근이 막혀 있다. private fun boardView( filter: String?, @@ -137,8 +137,8 @@ class AdminExtractionPolicyController( // knownAccess: 저장된 access 문자열이 이 바이너리의 enum 에 있는가. 없으면(신버전이 만든 값 → 구버전 롤백) // select 에 고를 항목이 없으므로 화면이 경고를 띄우고 운영자가 알려진 정책으로 교체하거나 삭제하게 한다. // - // 허가 입력값은 selectedAccess 와 같은 방식으로 컨트롤러가 확정해 넘긴다 — 에러 재표시에서는 방금 제출한 - // 값을, 평소에는 저장된 값을 보여준다(체크 해제는 draft 가 false 라 그대로 유지된다). + // 허락 근거도 selectedAccess 와 같은 방식으로 컨트롤러가 확정해 넘긴다 — 에러 재표시에서는 방금 제출한 + // 값을, 평소에는 저장된 값을 보여준다. private fun detailView( policy: ExtractionPolicyView, model: Model, 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 959b59d79..0bd5ab46a 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyService.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyService.kt @@ -146,7 +146,7 @@ class AdminExtractionPolicyService( permissionRef: String?, ) { // 허락은 사람이 플랫폼에서 받아 오는 것이라, 켜는 순간 근거를 함께 남기게 강제한다. 근거 없는 허락이 - // 쌓이면 원장이 "왜 열려 있나"에 답하지 못해 원장 구실을 못 한다 — 이 값은 우회 수단을 여는 값이라 특히 그렇다. + // 쌓이면 원장이 "왜 열려 있나"에 답하지 못해 원장 구실을 못 한다 — 이 값은 적극적인 수단을 여는 값이라 특히 그렇다. require(access != DomainAccess.ALLOWED || !permissionRef.isNullOrBlank()) { "허락(ALLOWED)을 지정하려면 근거를 함께 남겨 주세요 (예: 메일 스레드·담당자)." } 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 f506468da..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 대역·렌더러 시간(허락받은 도메인은 프록시까지)·이미지 저장·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 index 496d469bd..d3d03ec44 100644 --- a/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccess.kt +++ b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccess.kt @@ -1,13 +1,14 @@ package com.depromeet.piki.product.routing // 도메인 하나를 어떻게 대하는가. 축은 이것 하나이고 값은 둘뿐이다 — 행이 없으면 "아직 판단하지 않음"이라 -// 기본 흐름(정적 fetch -> 필요하면 브라우저 승격)을 그대로 탄다. +// 기본 수단만 쓰는 흐름을 그대로 탄다. // // 옛 ExtractionRoute 가 세 질문(등록을 받나 / 어떻게 가져오나 / 실측했나)에 한 축으로 답하다 모순 조합을 -// 만들어냈던 것이 이 enum 을 값 둘로 좁힌 이유다. "어떻게 가져오나"는 추출 체인이 관측으로 스스로 정하고, +// 만들어냈던 것이 이 enum 을 값 둘로 좁힌 이유다. "어떻게 가져오나"는 추출 모듈이 스스로 정하고, // 여기서는 "받을 것인가"와 "어디까지 허락됐나"만 답한다. enum class DomainAccess { - // 플랫폼의 명시적 허락을 받았다. 등록을 받고, 렌더 서비스의 우회 수단(지문 보정·프록시)까지 열린다. + // 플랫폼의 명시적 허락을 받았다. 등록을 받고, 추출 모듈이 적극적인 수단까지 쓸 수 있게 된다 — + // 그 수단이 무엇인지는 모듈이 정하며 이쪽은 알지 않는다(알면 모듈이 바뀔 때 이 서술이 조용히 낡는다). // 허락은 사람이 받아 오는 것이라 근거(permissionRef) 없이는 켤 수 없다(입력 경계가 강제). ALLOWED, diff --git a/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicy.kt b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicy.kt index 5a72d8059..5f5677ba0 100644 --- a/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicy.kt +++ b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicy.kt @@ -8,15 +8,15 @@ import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component // 도메인별 접근 정책의 단일 결정 지점. "이 도메인을 어떻게 대하나"의 판정이 전부 여기로 모인다 — -// 등록 거절(BLOCKED)과 우회 허락(ALLOWED) 둘 뿐이고, 정책 행이 없는 도메인은 기본 흐름을 그대로 탄다. +// 등록 거절(BLOCKED)과 허락(ALLOWED) 둘 뿐이고, 정책 행이 없는 도메인은 기본 흐름을 그대로 탄다. // // 인터페이스/구현 분리는 알림의 NotificationTemplateProvider 와 같은 구조 — 소비자(등록 경계·원격 클라이언트)의 // 단위 테스트가 DB 없이 정책을 대체할 수 있게 한다. interface DomainAccessPolicy { - // 이 링크의 host 에 지정된 접근 정책. 행이 없으면 null — 등록을 받고, 우회 없이 기본 흐름을 탄다. + // 이 링크의 host 에 지정된 접근 정책. 행이 없으면 null — 등록을 받고, 기본 수단만 쓰는 흐름을 탄다. fun accessOf(link: ProductLink): DomainAccess? - // 이 host 를 어떻게든 뚫어도 되는가 — 플랫폼에서 받은 명시적 허락의 판정. 기본은 거부다: + // 이 host 에 적극적인 수단까지 써도 되는가 — 플랫폼에서 받은 명시적 허락의 판정. 기본은 거부다: // 정책 행이 없으면(= 대부분의 도메인) false 다. 인터페이스 기본 구현을 false 로 두는 이유는 그 default-deny 를 // 시그니처에 박기 위해서다 — 원장을 아는 구현만 true 를 낼 수 있고, 모르는 대체 구현은 거부로 수렴한다. fun authorizedFor(link: ProductLink): Boolean = false diff --git a/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntity.kt b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntity.kt index eefb3207a..518dd7d26 100644 --- a/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntity.kt +++ b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntity.kt @@ -7,7 +7,7 @@ import jakarta.persistence.Table import java.time.LocalDateTime // 도메인별 접근 정책 행. domain(정규형: 소문자·trailing dot 없음)이 자연키(PK)라 같은 도메인 문자열의 정책은 -// 정확히 하나다 — 옛 스키마에서 route 와 허가 boolean 이 갈라져 "브라우저로 가라 + 안 된다" 같은 모순을 +// 정확히 하나다 — 옛 스키마에서 route 와 허가 boolean 이 갈라져 "이렇게 가져와라 + 받지 마라" 같은 모순을 // 저장할 수 있던 것을 축 하나로 합쳐 원천 차단한다. @Entity @Table(name = "domain_access_policies") @@ -31,7 +31,7 @@ class DomainAccessPolicyEntity( // 경로가 정규화를 빠뜨리는 코드 버그를 잡는 층이다. require(domain.isNotBlank()) { "domain 이 비어 있습니다." } require(domain == domain.trim().trimEnd('.').lowercase()) { "domain 은 정규형(소문자·trailing dot 없음)이어야 합니다." } - // 허락은 근거가 있어야 허락이다. 근거 없는 ALLOWED 는 우회 수단을 여는 값이 아무 흔적 없이 켜진 상태라, + // 허락은 근거가 있어야 허락이다. 근거 없는 ALLOWED 는 적극적인 수단을 여는 값이 아무 흔적 없이 켜진 상태라, // 몇 달 뒤 "왜 켰지"를 되짚을 수 없고 지워도 되는지도 판단할 수 없다. require(access != DomainAccess.ALLOWED.name || !permissionRef.isNullOrBlank()) { "허락 근거(permissionRef) 없이 ALLOWED 정책을 만들 수 없습니다." 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 a553a2ee0..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 @@ -19,8 +19,8 @@ import org.springframework.web.client.RestClient // // authorized: "이 대상이 플랫폼의 명시적 허락을 받았는가"의 판정. 원장(domain_access_policies)은 이쪽 DB 에만 // 있고 extractor·renderer 는 무상태라, 요청 단위로 실어 보낸다. 기본은 거부라 정책 행이 없는 대부분의 도메인은 -// false 로 나간다. 이 값이 여는 것은 렌더 서비스의 우회 수단(지문 보정·프록시)뿐이고, 브라우저를 여는 것 자체는 -// 허락과 무관하다 — 신원을 밝히고 우리 IP 로 가는 일이라 전제가 필요 없다. +// false 로 나간다. 이 값이 여는 것은 추출 모듈이 쓸 수 있는 수단의 범위이고, 그 수단이 무엇인지는 모듈이 +// 정한다 — 여기서 알면 저쪽 구현이 바뀔 때 이 서술만 조용히 낡는다. 기본 수단으로 가는 데는 허락이 필요 없다. // // model 도 같은 이유로 요청에 싣는다(#875). extractor 박스 하나를 여러 환경이 공유하므로 저쪽 환경변수로 // 모델을 잡으면 dev 실험이 prod 를 덮는다 — 요청 단위로 주면 환경마다 다른 이쪽 DB 가 그대로 경계가 된다. @@ -45,7 +45,7 @@ class HttpProductLinkExtractor( request = RemoteLinkExtractionRequest( url = link.value.toString(), - // 허락 판정의 원장은 core 다. extractor·renderer 는 이 값을 받아 우회 수단을 열 뿐, + // 허락 판정의 원장은 core 다. extractor·renderer 는 이 값만큼 수단을 열 뿐, // 무엇이 허락됐는지 스스로 알지 않는다(무상태). authorized = accessPolicy.authorizedFor(link), model = modelSettings.modelOf(ExtractionTarget.LINK), 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 02da17ffe..7d0b458d6 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 1c923919f..6fbf157fe 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -20,7 +20,7 @@ import java.util.UUID @Service class TournamentItemService( private val tournamentItemPersistenceService: TournamentItemPersistenceService, - private val extractionRoutingPolicy: DomainAccessPolicy, + private val accessPolicy: DomainAccessPolicy, private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, private val tournamentRepository: TournamentRepository, @@ -54,7 +54,7 @@ class TournamentItemService( val link = ProductLink.parse(url) // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다(400). // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — DomainAccessPolicy 참고. - extractionRoutingPolicy.verifyRegistrable(link) + 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 26e2d959b..8839253b9 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -32,7 +32,7 @@ import java.util.UUID @Service class WishlistService( private val wishPersistenceService: WishPersistenceService, - private val extractionRoutingPolicy: DomainAccessPolicy, + private val accessPolicy: DomainAccessPolicy, private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, private val wishRepository: WishRepository, @@ -65,9 +65,9 @@ class WishlistService( val link = ProductLink.parse(rawUrl) // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다. // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — DomainAccessPolicy 참고. - extractionRoutingPolicy.verifyRegistrable(link) + 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 6e7ccd3d5..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 대역, 렌더러 시간(허락받은 도메인은 프록시까지), 이미지 저장, 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 index 2c836b8a8..8997f2052 100644 --- 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 @@ -1,7 +1,7 @@ -- 플랫폼 정책 테이블을 "허용/차단" 한 축으로 교체한다. -- --- 왜 이관하지 않고 새로 만드나: 옛 route 3값(SUPPORTED·HEADLESS_FIRST·UNSUPPORTED)과 허가 boolean 은 --- 서로 다른 질문(등록을 받나 / 어떻게 가져오나 / 확인했나 / 브라우저를 써도 되나)에 한 축으로 답하고 있어 +-- 왜 이관하지 않고 새로 만드나: 옛 route 3값과 허가 boolean 은 +-- 서로 다른 질문(등록을 받나 / 어떻게 가져오나 / 확인했나 / 어디까지 허락됐나)에 한 축으로 답하고 있어 -- 기계적 매핑이 성립하지 않는다. 같은 HEADLESS_FIRST 라도 kream 은 차단이고 store.kakao 는 차단이 아니라 -- 갈 곳이 다르다. 그래서 값을 옮기지 않고 비운 채 시작하며, 판단이 선 도메인만 백오피스에서 다시 넣는다. -- @@ -9,8 +9,8 @@ -- "아직 판단하지 않았다"는 사실의 정직한 반영이다 — 자동 차단 감지(후속)나 메일 회신으로 판단이 서면 그때 -- BLOCKED 로 넣는다. 반대로 근거 없이 미리 채우면 옛 테이블이 좀비 행을 갖게 된 경로를 그대로 반복한다. -- --- ALLOWED 는 "플랫폼의 명시적 허락을 받았다"는 뜻이고, 그 결과로 렌더 서비스의 우회 수단(지문 보정·프록시)이 --- 열린다. 허락은 사람이 받아 오는 것이라 근거(permission_ref) 없이는 켤 수 없다 — 입력 경계가 그걸 강제한다. +-- ALLOWED 는 "플랫폼의 명시적 허락을 받았다"는 뜻이고, 그 결과로 추출 모듈이 적극적인 수단까지 쓸 수 있게 +-- 된다. 허락은 사람이 받아 오는 것이라 근거(permission_ref) 없이는 켤 수 없다 — 입력 경계가 그걸 강제한다. CREATE TABLE domain_access_policies ( domain VARCHAR(255) NOT NULL, -- ALLOWED | BLOCKED. 값 이름을 enum 이 아니라 문자열로 두는 이유는 옛 테이블과 같다 — 이 바이너리가 diff --git a/src/main/resources/templates/admin/extraction-policies.html b/src/main/resources/templates/admin/extraction-policies.html index d053e1c42..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 백오피스
정책추출을 어떻게 하나언제 이 정책을 쓰나
정책무엇을 하나언제 이 정책을 쓰나
정책 없음기본 추출
(정적 fetch → 구조화/LLM) - 두 정책의 추출 방법은 완전히 같습니다.
아직 확인하지 않은 도메인. 목록에 없는 모든 도메인이 여기 해당합니다.기본 추출
(정적 fetch → 안 되면 브라우저)
대부분의 도메인. 우리 신원을 밝히고 우리 IP 로 가며, 막히면 막힌 대로 실패로 남깁니다.
SUPPORTED기본 추출로 잘 된다고 실측으로 확인한 기록입니다. 추출 방법을 바꾸지 않고 사실만 남깁니다.ALLOWED기본 추출 +
우회 수단 허용
플랫폼에서 명시적으로 허락을 받은 도메인. 차단을 만나도 우회 수단(프록시 등)을 써서 가져옵니다. + 허락은 사람이 받아 오는 것이라 근거를 함께 남겨야 지정됩니다 (메일 스레드·담당자).
HEADLESS_FIRST정적 fetch 를 건너뛰고
브라우저로 페이지를 열어 추출
정적 fetch 가 늘 차단되는 곳. 실패할 것이 뻔한 시도를 기다리지 않습니다. - 플랫폼의 헤드리스 허가를 받은 도메인만 지정할 수 있습니다 — 허가는 상세 화면에서 근거와 함께 켭니다. - 원격 추출(extractor) 경로에서만 유효하며, extractor 의 헤드리스 스위치가 꺼진 환경에서는 지정해도 효과가 없습니다.
UNSUPPORTED추출하지 않음
(등록 요청을 400 으로 거절)
봇 차단 등으로 어떤 방법으로도 상품 정보를 가져올 수 없는 곳.BLOCKED요청하지 않음
(등록은 400 으로 거절)
어떤 방법으로도 가져올 수 없다고 판단한 곳. 새 등록을 막고, 이미 담긴 아이템도 다시 요청하지 않습니다 + — 거부 의사를 확인한 곳에 반복해서 두드리지 않는다는 뜻입니다.