Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
41a32b6
feat(pesticide): PSIS 응답 파서에 resultCode/resultMsg/totalCount 봉투 파싱 추가
Kimseungin0529 Jul 13, 2026
b48023e
feat(pesticide): PSIS 동기화가 업스트림 에러 resultCode에서 즉시 실패하도록 수정
Kimseungin0529 Jul 13, 2026
29d3cb0
feat(pesticide): RowMapper에 필수 필드 매핑 진단 기능 추가
Kimseungin0529 Jul 13, 2026
2360821
feat(pesticide): DB 쓰기 없이 PSIS 응답을 확인하는 프로브 기능 추가
Kimseungin0529 Jul 13, 2026
b16e50f
feat(pesticide): 관리자 프로브 엔드포인트 추가
Kimseungin0529 Jul 13, 2026
c9ce1dc
docs(pesticide): PSIS 동기화 활성화 런북 추가
Kimseungin0529 Jul 13, 2026
75267a6
refactor(pesticide): 코드리뷰 반영 - item 노드 hoist 및 페이지 쿼리 빌더 추출
Kimseungin0529 Jul 13, 2026
501cec9
fix(pesticide): 동기화를 실제 PSIS-RDA API 규격으로 정정
Kimseungin0529 Jul 13, 2026
c68e0d9
feat(pesticide): 대용량 동기화를 비동기 진행상태 잡으로 전환
Kimseungin0529 Jul 13, 2026
b756d74
docs(pesticide): PSIS 실규격·비동기 잡 기준으로 런북 갱신
Kimseungin0529 Jul 13, 2026
da9f8b9
fix(pesticide): 동기화가 totalCount 미달로 끝나면 잡을 실패 처리
Kimseungin0529 Jul 13, 2026
5e0955d
test(pesticide): 관리자 토큰 없이 실 DB에 적재하는 수동 로더 추가
Kimseungin0529 Jul 13, 2026
5abf200
test(pesticide): 적재 데이터 검증 하네스 추가 및 로컬 키 파일 gitignore
Kimseungin0529 Jul 13, 2026
b958bf0
test(pesticide): 적재+조회 검증을 한 실행으로 병합해 ddl-auto 삭제 함정 제거
Kimseungin0529 Jul 13, 2026
a874709
docs(pesticide): 서버 DB 반영 dump/restore 핸드오프 절차 추가
Kimseungin0529 Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,7 @@ backend/api/src/test/kotlin/com/chamchamcham/api/dev/
backend/application/src/main/kotlin/com/chamchamcham/application/coaching/rag/seed/
backend/application/src/test/kotlin/com/chamchamcham/application/coaching/rag/seed/
backend/docs/db/crop-seed.sql

# 로컬 전용 시크릿(예: PSIS 농약 API 키) — 절대 커밋 금지
backend/.psis.env
.psis.env
Original file line number Diff line number Diff line change
@@ -1,26 +1,72 @@
package com.chamchamcham.api.pesticide.controller

import com.chamchamcham.api.common.ApiResponse
import com.chamchamcham.application.pesticide.sync.PesticideSyncResult
import com.chamchamcham.api.pesticide.dto.PesticideResponses.PesticideProbeResponse
import com.chamchamcham.api.pesticide.dto.PesticideResponses.PesticideSyncJobDetailResponse
import com.chamchamcham.api.pesticide.dto.PesticideResponses.PesticideSyncJobSummaryResponse
import com.chamchamcham.application.exception.ErrorCode
import com.chamchamcham.application.exception.business.BusinessException
import com.chamchamcham.application.pesticide.sync.PesticideSyncAsyncRunner
import com.chamchamcham.application.pesticide.sync.PesticideSyncService
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.UUID

/**
* PSIS 데이터를 서버 DB로 1회성 동기화하는 관리자 트리거. 137k건 전체를 순회하므로 시간이 걸릴 수
* 있어 요청-응답을 동기로 유지한다(진행 상황 폴링이 필요해지면 PolicySyncJob처럼 비동기+상태추적으로
* 확장 - YAGNI).
* PSIS 데이터를 서버 DB로 동기화하는 관리자 트리거. 143k건 전체를 순회하므로 시간이 걸려
* RUNNING 잡을 즉시 반환하고 실제 순회는 [PesticideSyncAsyncRunner]가 비동기로 수행한다
* (PolicySyncJob과 동일한 패턴). 진행 상태는 GET .../{jobId}로 폴링한다.
*/
@RestController
@RequestMapping("/api/v1/admin/pesticide-sync")
class AdminPesticideSyncController(
private val pesticideSyncService: PesticideSyncService
private val pesticideSyncService: PesticideSyncService,
private val pesticideSyncAsyncRunner: PesticideSyncAsyncRunner,
) {
@PostMapping
fun sync(): ResponseEntity<ApiResponse<PesticideSyncResult>> {
val result = pesticideSyncService.sync()
return ResponseEntity.ok(ApiResponse.ok(result))
fun createSyncJob(
@AuthenticationPrincipal principal: Any?,
): ResponseEntity<ApiResponse<PesticideSyncJobSummaryResponse>> {
val result = pesticideSyncService.createSyncJob(parseMemberId(principal))
pesticideSyncAsyncRunner.run(result.jobId)
return ResponseEntity.ok(ApiResponse.ok(PesticideSyncJobSummaryResponse.from(result)))
}

@GetMapping("/{jobId}")
fun getJob(
@PathVariable jobId: UUID,
): ResponseEntity<ApiResponse<PesticideSyncJobDetailResponse>> {
val result = pesticideSyncService.getJob(jobId)
return ResponseEntity.ok(ApiResponse.ok(PesticideSyncJobDetailResponse.from(result)))
}

@PostMapping("/probe")
fun probe(
@RequestParam(defaultValue = "10") rows: Int,
): ResponseEntity<ApiResponse<PesticideProbeResponse>> {
if (rows !in 1..50) {
throw BusinessException(ErrorCode.INVALID_INPUT)
}
val result = pesticideSyncService.probe(rows)
return ResponseEntity.ok(ApiResponse.ok(PesticideProbeResponse.from(result)))
}

// pesticide sync job의 createdByMemberId는 nullable이라(관리 트리거는 인증 주체가 없어도 되는
// 1회성 작업), 정책 동기화 컨트롤러와 달리 principal이 없거나 UUID 형식이 아니어도 401 대신 null로
// 처리한다.
private fun parseMemberId(principal: Any?): UUID? {
val memberId = when (principal) {
is String -> principal
is UserDetails -> principal.username
else -> null
}
return memberId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package com.chamchamcham.api.pesticide.dto

import com.chamchamcham.application.pesticide.PesticideResult
import com.chamchamcham.application.pesticide.sync.PesticideProbeResult
import com.chamchamcham.application.pesticide.sync.PesticideSyncResult
import com.chamchamcham.application.pesticide.sync.PsisPesticideRow
import com.chamchamcham.domain.pesticide.PesticideSyncJobStatus
import java.time.LocalDateTime
import java.util.UUID

object PesticideResponses {
Expand Down Expand Up @@ -45,4 +50,66 @@ object PesticideResponses {
)
}
}

data class PesticideProbeResponse(
val errorCode: String?,
val errorMsg: String?,
val totalCount: Int?,
val itemCount: Int,
val distinctTagNames: List<String>,
val sampleRawItem: Map<String, String>?,
val requiredKeyResolution: Map<String, Boolean>,
val mapped: PsisPesticideRow?,
) {
companion object {
fun from(result: PesticideProbeResult): PesticideProbeResponse = PesticideProbeResponse(
errorCode = result.errorCode,
errorMsg = result.errorMsg,
totalCount = result.totalCount,
itemCount = result.itemCount,
distinctTagNames = result.distinctTagNames,
sampleRawItem = result.sampleRawItem,
requiredKeyResolution = result.requiredKeyResolution,
mapped = result.mapped,
)
}
}

data class PesticideSyncJobSummaryResponse(
val jobId: UUID,
val status: PesticideSyncJobStatus,
) {
companion object {
fun from(result: PesticideSyncResult.JobSummary): PesticideSyncJobSummaryResponse =
PesticideSyncJobSummaryResponse(
jobId = result.jobId,
status = result.status,
)
}
}

data class PesticideSyncJobDetailResponse(
val jobId: UUID,
val status: PesticideSyncJobStatus,
val totalCount: Int,
val fetchedRowCount: Int,
val createdApplicationCount: Int,
val errorMessage: String?,
val startedAt: LocalDateTime,
val finishedAt: LocalDateTime?,
) {
companion object {
fun from(result: PesticideSyncResult.JobDetail): PesticideSyncJobDetailResponse =
PesticideSyncJobDetailResponse(
jobId = result.jobId,
status = result.status,
totalCount = result.totalCount,
fetchedRowCount = result.fetchedRowCount,
createdApplicationCount = result.createdApplicationCount,
errorMessage = result.errorMessage,
startedAt = result.startedAt,
finishedAt = result.finishedAt,
)
}
}
}
2 changes: 1 addition & 1 deletion backend/api/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ weather:
psis:
pesticide:
base-url: ${PSIS_PESTICIDE_BASE_URL:}
service-key: ${PSIS_PESTICIDE_SERVICE_KEY:}
api-key: ${PSIS_PESTICIDE_API_KEY:}
timeout-millis: ${PSIS_PESTICIDE_TIMEOUT_MILLIS:10000}

openai:
Expand Down
2 changes: 1 addition & 1 deletion backend/api/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ weather:
psis:
pesticide:
base-url: ${PSIS_PESTICIDE_BASE_URL:}
service-key: ${PSIS_PESTICIDE_SERVICE_KEY:}
api-key: ${PSIS_PESTICIDE_API_KEY:}
timeout-millis: ${PSIS_PESTICIDE_TIMEOUT_MILLIS:10000}

openai:
Expand Down
2 changes: 1 addition & 1 deletion backend/api/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ weather:
psis:
pesticide:
base-url: ${PSIS_PESTICIDE_BASE_URL:}
service-key: ${PSIS_PESTICIDE_SERVICE_KEY:}
api-key: ${PSIS_PESTICIDE_API_KEY:}
timeout-millis: ${PSIS_PESTICIDE_TIMEOUT_MILLIS:10000}

openai:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.chamchamcham.api.pesticide

import com.chamchamcham.application.pesticide.PesticideCatalogService
import com.chamchamcham.application.pesticide.sync.PesticideSyncService
import com.chamchamcham.domain.pesticide.PestRepository
import com.chamchamcham.domain.pesticide.PesticideApplicationRepository
import com.chamchamcham.domain.pesticide.PesticideRepository
import com.chamchamcham.domain.pesticide.PesticideSyncJobStatus
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.PageRequest
import org.springframework.test.context.ActiveProfiles

/**
* PSIS 농약등록정보(≈143,912건)를 실 DB에 1회성으로 적재하는 수동 로더.
*
* 관리자 토큰/HTTP 없이 실 Postgres에 바로 적재하기 위한 것으로, 일반 테스트 실행(`./gradlew test`)에서는
* 절대 돌지 않도록 전용 환경변수 `PSIS_PESTICIDE_SYNC_RUN=true`가 있을 때만 활성화된다.
*
* 실행 전제(로컬 앱 구동과 동일):
* - 로컬 Postgres(5444) + Redis 기동
* - env: `PSIS_PESTICIDE_API_KEY`(서비스인증키), `PSIS_PESTICIDE_BASE_URL=http://psis.rda.go.kr/openApi/service.do`
*
* 실행 예:
* PSIS_PESTICIDE_SYNC_RUN=true \
* PSIS_PESTICIDE_API_KEY=<서비스인증키> \
* PSIS_PESTICIDE_BASE_URL=http://psis.rda.go.kr/openApi/service.do \
* ./gradlew :api:test --tests "com.chamchamcham.api.pesticide.PesticideSyncManualLoaderTest"
*
* runExistingJob을 (비동기 러너가 아니라) 직접 호출해 동기로 끝까지 순회하므로, 전량 적재가 끝날 때까지
* 블로킹된다(수 분~수십 분). 재실행은 dedup되어 안전하다. totalCount에 미달하면 잡이 FAILED가 되어
* 아래 단언에서 실패로 드러난다.
*
* ⚠️ 적재와 조회 검증은 반드시 **같은 실행(=같은 Spring 컨텍스트)** 안에서 끝낸다. local 프로필은
* `ddl-auto: create`라 컨텍스트가 부팅될 때마다 스키마를 DROP/재생성하므로, 적재가 끝난 뒤 별도
* 테스트/앱을 local 프로필로 다시 띄우면 방금 넣은 데이터가 통째로 지워진다. 그래서 적재 직후
* 이 테스트 안에서 [PesticideCatalogService]로 검색·병해충 조회까지 함께 검증한다.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("local")
@EnabledIfEnvironmentVariable(named = "PSIS_PESTICIDE_SYNC_RUN", matches = "true")
class PesticideSyncManualLoaderTest @Autowired constructor(
private val pesticideSyncService: PesticideSyncService,
private val pesticideCatalogService: PesticideCatalogService,
private val pesticideRepository: PesticideRepository,
private val pestRepository: PestRepository,
private val pesticideApplicationRepository: PesticideApplicationRepository,
) {
@Test
fun `PSIS 농약등록정보를 실 DB에 전량 적재하고 조회를 검증한다`() {
val job = pesticideSyncService.createSyncJob(adminMemberId = null)
pesticideSyncService.runExistingJob(job.jobId)

val detail = pesticideSyncService.getJob(job.jobId)
println(
"[PSIS sync] status=${detail.status} total=${detail.totalCount} " +
"fetched=${detail.fetchedRowCount} createdApplications=${detail.createdApplicationCount} " +
"error=${detail.errorMessage}"
)

assertThat(detail.status).isEqualTo(PesticideSyncJobStatus.SUCCEEDED)
assertThat(detail.fetchedRowCount).isGreaterThan(0)

// 같은 컨텍스트에서 곧바로 조회 검증(별도 부팅 시 ddl-auto:create가 데이터를 지우므로 여기서 검증).
val pesticideCount = pesticideRepository.count()
val pestCount = pestRepository.count()
val applicationCount = pesticideApplicationRepository.count()
println("[verify] 적재 건수 pesticides=$pesticideCount pests=$pestCount applications=$applicationCount")

assertThat(pesticideCount).isGreaterThan(0)
assertThat(pestCount).isGreaterThan(0)
assertThat(applicationCount).isGreaterThan(0)

// 검색 API 로직(키워드 = 품목명/상표명 부분일치 + 커서 페이지네이션)
val page = pesticideCatalogService.search(keyword = "가스가마이신", cursor = null, size = 5)
println("[verify] search('가스가마이신') -> ${page.items.size}건, nextCursor존재=${page.nextCursor != null}")
println("[verify] 첫 결과=${page.items.firstOrNull()}")
assertThat(page.items).isNotEmpty

// 약제별 병해충 조회 API 로직
val anyPesticide = pesticideRepository.findAll(PageRequest.of(0, 1)).content.first()
val pests = pesticideCatalogService.listPestsByPesticide(requireNotNull(anyPesticide.id))
println("[verify] '${anyPesticide.itemName}/${anyPesticide.brandName}' -> 병해충 ${pests.size}종, 예시=${pests.firstOrNull()}")
assertThat(pests).isNotEmpty
}
}
Loading
Loading