diff --git a/api-admin/api-spec.yaml b/api-admin/api-spec.yaml index 075608bc..abdbdb5c 100644 --- a/api-admin/api-spec.yaml +++ b/api-admin/api-spec.yaml @@ -431,6 +431,63 @@ paths: responses: '204': {} + /closedPlaceCandidates: + get: + operationId: listClosedPlaceCandidates + summary: 폐업이 추정되는 장소의 리스트를 조회한다. + parameters: + - in: query + name: cursor + schema: + type: string + required: false + - in: query + name: limit + description: default 값은 50으로 설정된다. + schema: + type: string + required: false + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AdminListClosedPlaceCandidatesResponseDTO' + + /closedPlaceCandidates/{id}: + get: + operationId: getClosedPlaceCandidate + summary: 폐업이 추정되는 장소를 조회한다. + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AdminClosedPlaceCandidateDTO' + + /closedPlaceCandidates/{id}/accept: + put: + operationId: acceptClosedPlaceCandidate + summary: 해당 장소를 폐업 상태로 변경한다. + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AdminClosedPlaceCandidateDTO' + + /closedPlaceCandidates/{id}/ignore: + put: + operationId: ignoreClosedPlaceCandidate + summary: 해당 장소를 폐업의 폐업 추정을 무시한다. + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AdminClosedPlaceCandidateDTO' + + components: # Reusable schemas (data models) schemas: @@ -924,3 +981,35 @@ components: $ref: '#/components/schemas/LocationDTO' required: - boundaryVertices + + AdminListClosedPlaceCandidatesResponseDTO: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/AdminClosedPlaceCandidateDTO' + cursor: + type: string + description: 없으면 다음 페이지가 없다는 의미. + required: + - list + + AdminClosedPlaceCandidateDTO: + type: object + properties: + id: + type: string + placeId: + type: string + name: + type: string + address: + type: string + acceptedAt: + $ref: '#/components/schemas/EpochMillisTimestamp' + ignoredAt: + $ref: '#/components/schemas/EpochMillisTimestamp' + required: + - id + - placeId \ No newline at end of file diff --git a/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/AcceptClosedPlaceCandidateUseCase.kt b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/AcceptClosedPlaceCandidateUseCase.kt new file mode 100644 index 00000000..dfd54285 --- /dev/null +++ b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/AcceptClosedPlaceCandidateUseCase.kt @@ -0,0 +1,35 @@ +package club.staircrusher.place.application.port.`in` + +import club.staircrusher.place.application.port.out.persistence.ClosedPlaceCandidateRepository +import club.staircrusher.place.application.port.out.persistence.PlaceRepository +import club.staircrusher.place.application.result.NamedClosedPlaceCandidate +import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.stdlib.persistence.TransactionManager +import org.springframework.data.repository.findByIdOrNull + +@Component +class AcceptClosedPlaceCandidateUseCase( + private val transactionManager: TransactionManager, + private val closedPlaceCandidateRepository: ClosedPlaceCandidateRepository, + private val placeRepository: PlaceRepository, +) { + fun handle(candidateId: String) = transactionManager.doInTransaction { + val candidate = closedPlaceCandidateRepository.findByIdOrNull(candidateId) + ?: throw IllegalArgumentException("closed place candidate with id($candidateId) not found") + val place = placeRepository.findByIdOrNull(candidate.placeId)!! + + candidate.accept() + closedPlaceCandidateRepository.save(candidate) + place.setIsClosed(true) + placeRepository.save(place) + + return@doInTransaction NamedClosedPlaceCandidate( + candidateId = candidate.id, + placeId = place.id, + name = place.name, + address = place.address.toString(), + acceptedAt = candidate.acceptedAt, + ignoredAt = candidate.ignoredAt, + ) + } +} diff --git a/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/GetClosedPlaceCandidateUseCase.kt b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/GetClosedPlaceCandidateUseCase.kt new file mode 100644 index 00000000..365a6d86 --- /dev/null +++ b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/GetClosedPlaceCandidateUseCase.kt @@ -0,0 +1,29 @@ +package club.staircrusher.place.application.port.`in` + +import club.staircrusher.place.application.port.out.persistence.ClosedPlaceCandidateRepository +import club.staircrusher.place.application.port.out.persistence.PlaceRepository +import club.staircrusher.place.application.result.NamedClosedPlaceCandidate +import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.stdlib.persistence.TransactionManager +import org.springframework.data.repository.findByIdOrNull + +@Component +class GetClosedPlaceCandidateUseCase( + private val transactionManager: TransactionManager, + private val closedPlaceCandidateRepository: ClosedPlaceCandidateRepository, + private val placeRepository: PlaceRepository, +) { + fun handle(candidateId: String) = transactionManager.doInTransaction { + val candidate = closedPlaceCandidateRepository.findByIdOrNull(candidateId) ?: return@doInTransaction null + val place = placeRepository.findByIdOrNull(candidate.placeId) ?: return@doInTransaction null + + return@doInTransaction NamedClosedPlaceCandidate( + candidateId = candidate.id, + placeId = place.id, + name = place.name, + address = place.address.toString(), + acceptedAt = candidate.acceptedAt, + ignoredAt = candidate.ignoredAt, + ) + } +} diff --git a/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/IgnoreClosedPlaceCandidateUseCase.kt b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/IgnoreClosedPlaceCandidateUseCase.kt new file mode 100644 index 00000000..852180a0 --- /dev/null +++ b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/IgnoreClosedPlaceCandidateUseCase.kt @@ -0,0 +1,33 @@ +package club.staircrusher.place.application.port.`in` + +import club.staircrusher.place.application.port.out.persistence.ClosedPlaceCandidateRepository +import club.staircrusher.place.application.port.out.persistence.PlaceRepository +import club.staircrusher.place.application.result.NamedClosedPlaceCandidate +import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.stdlib.persistence.TransactionManager +import org.springframework.data.repository.findByIdOrNull + +@Component +class IgnoreClosedPlaceCandidateUseCase( + private val transactionManager: TransactionManager, + private val closedPlaceCandidateRepository: ClosedPlaceCandidateRepository, + private val placeRepository: PlaceRepository, +) { + fun handle(candidateId: String) = transactionManager.doInTransaction { + val candidate = closedPlaceCandidateRepository.findByIdOrNull(candidateId) + ?: throw IllegalArgumentException("closed place candidate with id($candidateId) not found") + + candidate.ignore() + closedPlaceCandidateRepository.save(candidate) + + val place = placeRepository.findByIdOrNull(candidate.placeId)!! + return@doInTransaction NamedClosedPlaceCandidate( + candidateId = candidate.id, + placeId = place.id, + name = place.name, + address = place.address.toString(), + acceptedAt = candidate.acceptedAt, + ignoredAt = candidate.ignoredAt, + ) + } +} diff --git a/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/ListClosedPlaceCandidatesUseCase.kt b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/ListClosedPlaceCandidatesUseCase.kt new file mode 100644 index 00000000..a83ef050 --- /dev/null +++ b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/in/ListClosedPlaceCandidatesUseCase.kt @@ -0,0 +1,91 @@ +package club.staircrusher.place.application.port.`in` + +import club.staircrusher.place.application.port.out.persistence.ClosedPlaceCandidateRepository +import club.staircrusher.place.application.port.out.persistence.PlaceRepository +import club.staircrusher.place.application.result.NamedClosedPlaceCandidate +import club.staircrusher.place.domain.model.ClosedPlaceCandidate +import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.stdlib.persistence.TimestampCursor +import club.staircrusher.stdlib.persistence.TransactionManager +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import java.time.Instant + +@Component +class ListClosedPlaceCandidatesUseCase( + private val transactionManager: TransactionManager, + private val closedPlaceCandidateRepository: ClosedPlaceCandidateRepository, + private val placeRepository: PlaceRepository, +) { + fun handle( + limit: Int?, + cursorValue: String?, + ) = transactionManager.doInTransaction { + val cursor = cursorValue?.let { Cursor.parse(it) } ?: Cursor.initial() + val normalizedLimit = limit ?: DEFAULT_LIMIT + + val pageRequest = PageRequest.of( + 0, + normalizedLimit, + Sort.by( + listOf( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id"), + ), + ), + ) + val result = closedPlaceCandidateRepository.findCursored( + cursorCreatedAt = cursor.timestamp, + cursorId = cursor.id, + pageable = pageRequest, + ) + + val nextCursor = if (result.hasNext()) { + Cursor(result.content[normalizedLimit - 1]) + } else { + null + } + + val placeIds = result.content.map { it.placeId } + val places = placeRepository.findAllByIdIn(placeIds) + return@doInTransaction ListClosedPlaceCandidatesResult( + candidates = result.mapNotNull { candidate -> + val place = places.find { it.id == candidate.placeId } ?: return@mapNotNull null + NamedClosedPlaceCandidate( + candidateId = candidate.id, + placeId = place.id, + name = place.name, + address = place.address.toString(), + acceptedAt = candidate.acceptedAt, + ignoredAt = candidate.ignoredAt, + ) + }, + nextCursor = nextCursor?.value, + ) + } + + data class ListClosedPlaceCandidatesResult( + val candidates: List, + val nextCursor: String?, + ) + + private data class Cursor( + val createdAt: Instant, + val candidateId: String, + ) : TimestampCursor(createdAt, candidateId) { + constructor(candidate: ClosedPlaceCandidate) : this( + createdAt = candidate.createdAt, + candidateId = candidate.id, + ) + + companion object { + fun parse(cursorValue: String) = TimestampCursor.parse(cursorValue) + + fun initial() = TimestampCursor.initial() + } + } + + companion object { + private const val DEFAULT_LIMIT = 50 + } +} diff --git a/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/out/persistence/ClosedPlaceCandidateRepository.kt b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/out/persistence/ClosedPlaceCandidateRepository.kt index 2bf64355..8fda6c59 100644 --- a/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/out/persistence/ClosedPlaceCandidateRepository.kt +++ b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/port/out/persistence/ClosedPlaceCandidateRepository.kt @@ -1,8 +1,26 @@ package club.staircrusher.place.application.port.out.persistence import club.staircrusher.place.domain.model.ClosedPlaceCandidate +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.CrudRepository +import java.time.Instant interface ClosedPlaceCandidateRepository : CrudRepository { + @Query(""" + SELECT c + FROM ClosedPlaceCandidate c + WHERE + ( + (c.createdAt = :cursorCreatedAt AND c.id < :cursorId) + OR (c.createdAt < :cursorCreatedAt) + ) + """) + fun findCursored( + cursorCreatedAt: Instant, + cursorId: String, + pageable: Pageable, + ): Page fun findByExternalIdIn(externalIds: List): List } diff --git a/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/result/NamedClosedPlaceCandidate.kt b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/result/NamedClosedPlaceCandidate.kt new file mode 100644 index 00000000..44486a94 --- /dev/null +++ b/app-server/subprojects/bounded_context/place/application/src/main/kotlin/club/staircrusher/place/application/result/NamedClosedPlaceCandidate.kt @@ -0,0 +1,12 @@ +package club.staircrusher.place.application.result + +import java.time.Instant + +data class NamedClosedPlaceCandidate( + val candidateId: String, + val placeId: String, + val name: String, + val address: String, + val acceptedAt: Instant?, + val ignoredAt: Instant?, +) diff --git a/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/ClosedPlaceCandidate.kt b/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/ClosedPlaceCandidate.kt index ef04c838..b835c6e0 100644 --- a/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/ClosedPlaceCandidate.kt +++ b/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/ClosedPlaceCandidate.kt @@ -1,5 +1,6 @@ package club.staircrusher.place.domain.model +import club.staircrusher.stdlib.clock.SccClock import club.staircrusher.stdlib.persistence.jpa.TimeAuditingBaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity @@ -23,6 +24,14 @@ class ClosedPlaceCandidate( @Column(nullable = true) var ignoredAt: Instant? = null, ) : TimeAuditingBaseEntity() { + fun accept() { + acceptedAt = SccClock.instant() + } + + fun ignore() { + ignoredAt = SccClock.instant() + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/app-server/subprojects/bounded_context/place/infra/src/main/kotlin/club/staircrusher/place/infra/adapter/in/controller/AdminConverters.kt b/app-server/subprojects/bounded_context/place/infra/src/main/kotlin/club/staircrusher/place/infra/adapter/in/controller/AdminConverters.kt new file mode 100644 index 00000000..b697e024 --- /dev/null +++ b/app-server/subprojects/bounded_context/place/infra/src/main/kotlin/club/staircrusher/place/infra/adapter/in/controller/AdminConverters.kt @@ -0,0 +1,13 @@ +package club.staircrusher.place.infra.adapter.`in`.controller + +import club.staircrusher.admin_api.converter.toDTO +import club.staircrusher.place.application.result.NamedClosedPlaceCandidate + +fun NamedClosedPlaceCandidate.toAdminDTO() = club.staircrusher.admin_api.spec.dto.AdminClosedPlaceCandidateDTO( + id = candidateId, + placeId = placeId, + name = name, + address = address, + acceptedAt = acceptedAt?.toDTO(), + ignoredAt = ignoredAt?.toDTO(), +) diff --git a/app-server/subprojects/bounded_context/place/infra/src/main/kotlin/club/staircrusher/place/infra/adapter/in/controller/AdminPlaceController.kt b/app-server/subprojects/bounded_context/place/infra/src/main/kotlin/club/staircrusher/place/infra/adapter/in/controller/AdminPlaceController.kt index 2396dff1..2a0461ae 100644 --- a/app-server/subprojects/bounded_context/place/infra/src/main/kotlin/club/staircrusher/place/infra/adapter/in/controller/AdminPlaceController.kt +++ b/app-server/subprojects/bounded_context/place/infra/src/main/kotlin/club/staircrusher/place/infra/adapter/in/controller/AdminPlaceController.kt @@ -1,18 +1,64 @@ package club.staircrusher.place.infra.adapter.`in`.controller import club.staircrusher.admin_api.converter.toModel +import club.staircrusher.admin_api.spec.dto.AdminClosedPlaceCandidateDTO +import club.staircrusher.admin_api.spec.dto.AdminListClosedPlaceCandidatesResponseDTO import club.staircrusher.admin_api.spec.dto.StartPlaceCrawlingRequestDTO +import club.staircrusher.place.application.port.`in`.AcceptClosedPlaceCandidateUseCase +import club.staircrusher.place.application.port.`in`.GetClosedPlaceCandidateUseCase +import club.staircrusher.place.application.port.`in`.IgnoreClosedPlaceCandidateUseCase +import club.staircrusher.place.application.port.`in`.ListClosedPlaceCandidatesUseCase import club.staircrusher.place.application.port.`in`.StartPlaceCrawlingUseCase +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.PutMapping import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController class AdminPlaceController( private val startPlaceCrawlingUseCase: StartPlaceCrawlingUseCase, + private val listClosedPlaceCandidatesUseCase: ListClosedPlaceCandidatesUseCase, + private val getClosedPlaceCandidateUseCase: GetClosedPlaceCandidateUseCase, + private val acceptClosedPlaceCandidateUseCase: AcceptClosedPlaceCandidateUseCase, + private val ignoreClosedPlaceCandidateUseCase: IgnoreClosedPlaceCandidateUseCase, ) { @PostMapping("/admin/places/startCrawling") fun startPlaceCrawling(@RequestBody request: StartPlaceCrawlingRequestDTO) { startPlaceCrawlingUseCase.handle(request.boundaryVertices.map { it.toModel() }) } + + @GetMapping("/admin/closed-place-candidates") + fun listClosedPlaceCandidates( + @RequestParam(required = false) limit: Int?, + @RequestParam(required = false) cursor: String?, + ): AdminListClosedPlaceCandidatesResponseDTO { + return listClosedPlaceCandidatesUseCase.handle( + limit = limit, + cursorValue = cursor, + ).run { + AdminListClosedPlaceCandidatesResponseDTO( + items = candidates.map { it.toAdminDTO() }, + cursor = nextCursor, + ) + } + } + + @GetMapping("/admin/closed-place-candidates/{candidateId}") + fun getClosedPlaceCandidate(@PathVariable candidateId: String): AdminClosedPlaceCandidateDTO { + return getClosedPlaceCandidateUseCase.handle(candidateId)?.toAdminDTO() + ?: throw IllegalArgumentException("closed place candidate with id($candidateId) not found") + } + + @PutMapping("/admin/closed-place-candidates/{candidateId}/accept") + fun acceptClosedPlaceCandidate(@PathVariable candidateId: String): AdminClosedPlaceCandidateDTO { + return acceptClosedPlaceCandidateUseCase.handle(candidateId).toAdminDTO() + } + + @PutMapping("/admin/closed-place-candidates/{candidateId}/ignore") + fun ignoreClosedPlaceCandidate(@PathVariable candidateId: String): AdminClosedPlaceCandidateDTO { + return ignoreClosedPlaceCandidateUseCase.handle(candidateId).toAdminDTO() + } } diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/resources/db/migration/V33__closed_place_candidate_table.sql b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/resources/db/migration/V33__closed_place_candidate_table.sql index 2095dba2..036c8d37 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/resources/db/migration/V33__closed_place_candidate_table.sql +++ b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/resources/db/migration/V33__closed_place_candidate_table.sql @@ -10,4 +10,5 @@ CREATE TABLE IF NOT EXISTS closed_place_candidate ( ); CREATE INDEX idx_closed_place_candidate_1 ON closed_place_candidate(place_id); -CREATE INDEX idx_closed_place_candidate_2 ON closed_place_candidate(external_id); \ No newline at end of file +CREATE INDEX idx_closed_place_candidate_2 ON closed_place_candidate(external_id); +CREATE INDEX idx_closed_place_candidate_3 ON closed_place_candidate(created_at); \ No newline at end of file