Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[폐업추정] 어드민용 API 구현 #396

Open
wants to merge 4 commits into
base: jason/20240912-crawl-closed-place
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
89 changes: 89 additions & 0 deletions api-admin/api-spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +49 to +50
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClosedPlaceCandidate 엔티티에 있는 placeId 필드를 Place 로 바꾸고 연관관계를 맺어도 되지만 아래와 같은 이유로 따로 조회하도록 했습니다

  1. Place 의 name 과 address 는 항상 필요해서 lazy loading 이 큰 의미가 없음.
  2. 그렇다고 ClosedPlaceCandidate 의 Place 를 eager loading 하자니 Place 가 Building 을 eager loading 함
  3. 이러면 ClosedPlaceCandidate 를 여러개 조회할 때 building 까지 join 해서 가져와야 함
  4. 이게 그냥은 안되고 3개의 테이블을 조인해서 native query 로 가져오거나 repository 메소드에 entitygraph + subgraph 로 처리해야 하는데 (ClosedPlaceCandidate 의 Place 의 빌딩을 가져오도록) 메소드가 복잡해지고 기술적인 맥락이 너무 많이지는 것 같아서 꺼려졌습니다

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<NamedClosedPlaceCandidate>,
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
}
}
Original file line number Diff line number Diff line change
@@ -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<ClosedPlaceCandidate, String> {
@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<ClosedPlaceCandidate>
fun findByExternalIdIn(externalIds: List<String>): List<ClosedPlaceCandidate>
}
Original file line number Diff line number Diff line change
@@ -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?,
)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
)
Loading
Loading