generated from pricelees/issue-pr-template
925 lines
37 KiB
Kotlin
925 lines
37 KiB
Kotlin
package com.sangdol.data
|
|
|
|
import com.sangdol.common.persistence.IDGenerator
|
|
import com.sangdol.common.persistence.TransactionExecutionUtil
|
|
import com.sangdol.common.utils.KoreaDateTime
|
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
|
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
|
import com.sangdol.roomescape.payment.business.domain.BankCode
|
|
import com.sangdol.roomescape.payment.business.domain.CardIssuerCode
|
|
import com.sangdol.roomescape.payment.business.domain.CardOwnerType
|
|
import com.sangdol.roomescape.payment.business.domain.CardType
|
|
import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode
|
|
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
|
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
|
import com.sangdol.roomescape.payment.business.domain.PaymentType
|
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
|
|
import com.sangdol.roomescape.supports.AdminFixture
|
|
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
|
import com.sangdol.roomescape.supports.randomPhoneNumber
|
|
import com.sangdol.roomescape.supports.randomString
|
|
import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty
|
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
|
import com.sangdol.roomescape.user.business.SIGNUP
|
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus
|
|
import com.sangdol.roomescape.user.dto.UserContactResponse
|
|
import io.kotest.core.test.TestCaseOrder
|
|
import jakarta.persistence.EntityManager
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.coroutines.sync.Semaphore
|
|
import org.springframework.beans.factory.annotation.Autowired
|
|
import org.springframework.jdbc.core.JdbcTemplate
|
|
import org.springframework.test.context.ActiveProfiles
|
|
import java.sql.Timestamp
|
|
import java.time.Instant
|
|
import java.time.LocalDateTime
|
|
import java.time.LocalTime
|
|
import java.time.ZoneId
|
|
|
|
@ActiveProfiles("test", "data")
|
|
abstract class AbstractDataInitializer(
|
|
val semaphore: Semaphore = Semaphore(permits = 10),
|
|
) : FunSpecSpringbootTest(
|
|
enableCleanerExtension = false
|
|
) {
|
|
@Autowired
|
|
lateinit var entityManager: EntityManager
|
|
|
|
@Autowired
|
|
lateinit var jdbcTemplate: JdbcTemplate
|
|
|
|
@Autowired
|
|
lateinit var transactionExecutionUtil: TransactionExecutionUtil
|
|
|
|
@Autowired
|
|
lateinit var idGenerator: IDGenerator
|
|
|
|
override fun testCaseOrder(): TestCaseOrder? = TestCaseOrder.Sequential
|
|
|
|
suspend fun initialize() {
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
|
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0")
|
|
|
|
jdbcTemplate.query("SHOW TABLES") { rs, _ ->
|
|
rs.getString(1).lowercase()
|
|
}.forEach {
|
|
jdbcTemplate.execute("TRUNCATE TABLE $it")
|
|
}
|
|
|
|
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1")
|
|
|
|
this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { sql ->
|
|
jdbcTemplate.execute(sql)
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun executeBatch(sql: String, batchArgs: List<Array<Any>>) {
|
|
semaphore.acquire()
|
|
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
|
jdbcTemplate.batchUpdate(sql, batchArgs)
|
|
}
|
|
|
|
semaphore.release()
|
|
}
|
|
}
|
|
|
|
class DefaultDataInitializer : AbstractDataInitializer() {
|
|
|
|
// 1. HQ Admin 추가
|
|
// 2. Store 추가 -> CreatedBy / UpdatedBy는 HQ Admin 중 한명으로 고정
|
|
// 3. Store Admin 추가 -> CreatedBy / UpdatedBy는 HQ Admin 본인으로 고정
|
|
init {
|
|
lateinit var superHQAdmin: AdminEntity
|
|
|
|
// 모든 테이블 초기화 + 지역 데이터 삽입 + HQ 관리자 1명 생성
|
|
beforeSpec {
|
|
initialize()
|
|
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
|
superHQAdmin = testAuthUtil.createAdmin(AdminFixture.hqDefault.apply {
|
|
this.createdBy = this.id
|
|
this.updatedBy = this.id
|
|
})
|
|
}
|
|
}
|
|
|
|
context("관리자, 매장, 테마 초기 데이터 생성") {
|
|
test("각 PermissionLevel 마다 20명의 HQ 관리자 생성") {
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
|
AdminPermissionLevel.entries.forEach {
|
|
repeat(20) { index ->
|
|
AdminFixture.create(
|
|
account = "hq_${it.name.lowercase()}_$index",
|
|
name = randomKoreanName(),
|
|
phone = randomPhoneNumber(),
|
|
type = AdminType.HQ,
|
|
permissionLevel = it
|
|
).apply {
|
|
this.createdBy = superHQAdmin.id
|
|
this.updatedBy = superHQAdmin.id
|
|
}.also {
|
|
entityManager.persist(it)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
test("전체 매장 생성") {
|
|
val storeDataInitializer = StoreDataInitializer()
|
|
val creatableAdminIds: List<String> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
|
|
entityManager.createQuery(
|
|
"SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)",
|
|
Long::class.java
|
|
).setParameter("type", AdminType.HQ)
|
|
.setParameter(
|
|
"permissionLevels",
|
|
listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE)
|
|
)
|
|
.resultList
|
|
}!!.map { it.toString() }
|
|
|
|
val sqlFile = storeDataInitializer.createStoreDataSqlFile(creatableAdminIds)
|
|
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
|
jdbcTemplate.execute(sqlFile.readText())
|
|
}
|
|
}
|
|
|
|
test("각 매장당 1명의 ${AdminPermissionLevel.FULL_ACCESS} 권한의 StoreManager 1명 + ${AdminPermissionLevel.WRITABLE}의 2명 + 나머지 권한은 3명씩 생성") {
|
|
val storeAdminCountsByPermissionLevel = mapOf(
|
|
AdminPermissionLevel.WRITABLE to 2,
|
|
AdminPermissionLevel.READ_ALL to 3,
|
|
AdminPermissionLevel.READ_SUMMARY to 3
|
|
)
|
|
|
|
val stores: List<StoreEntity> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
|
|
entityManager.createQuery(
|
|
"SELECT s FROM StoreEntity s",
|
|
StoreEntity::class.java
|
|
).resultList
|
|
}!!
|
|
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
|
stores.forEach { store ->
|
|
// StoreManager 1명 생성
|
|
val storeManager = AdminFixture.create(
|
|
account = store.name,
|
|
name = randomKoreanName(),
|
|
phone = randomPhoneNumber(),
|
|
type = AdminType.STORE,
|
|
storeId = store.id,
|
|
permissionLevel = AdminPermissionLevel.FULL_ACCESS
|
|
).apply {
|
|
this.createdBy = superHQAdmin.id
|
|
this.updatedBy = superHQAdmin.id
|
|
}.also {
|
|
entityManager.persist(it)
|
|
}
|
|
|
|
storeAdminCountsByPermissionLevel.forEach { (permissionLevel, count) ->
|
|
repeat(count) { index ->
|
|
AdminFixture.create(
|
|
account = "${store.name}-${permissionLevel.ordinal}${index}",
|
|
name = randomKoreanName(),
|
|
phone = randomPhoneNumber(),
|
|
type = AdminType.STORE,
|
|
storeId = store.id,
|
|
permissionLevel = permissionLevel
|
|
).apply {
|
|
this.createdBy = storeManager.id
|
|
this.updatedBy = storeManager.id
|
|
}.also {
|
|
entityManager.persist(it)
|
|
}
|
|
}
|
|
}
|
|
entityManager.flush()
|
|
entityManager.clear()
|
|
}
|
|
}
|
|
}
|
|
|
|
test("총 500개의 테마 생성: 지난 2년 전 부터 지금까지의 랜덤 테마 + active 상태인 1달 이내 생성 테마 10개") {
|
|
val creatableAdminIds: List<Long> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
|
|
entityManager.createQuery(
|
|
"SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)",
|
|
Long::class.java
|
|
).setParameter("type", AdminType.HQ)
|
|
.setParameter(
|
|
"permissionLevels",
|
|
listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE)
|
|
)
|
|
.resultList
|
|
}!!
|
|
val sql =
|
|
"INSERT INTO theme (id, name, description, thumbnail_url, is_active, available_minutes, expected_minutes_from, expected_minutes_to, price, difficulty, min_participants, max_participants, created_at, created_by, updated_at, updated_by) VALUES (?," +
|
|
"?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
val batchSize = 100
|
|
val batchArgs = mutableListOf<Array<Any>>()
|
|
|
|
repeat(500) { i ->
|
|
val randomDay = if (i <= 9) (7..30).random() else (30..365 * 2).random()
|
|
val randomCreatedAt: LocalDateTime = LocalDateTime.now().minusDays(randomDay.toLong())
|
|
val randomThemeName =
|
|
(1..7).random().let { repeat -> (1..repeat).joinToString("") { randomKoreanName() } }
|
|
val availableMinutes = (6..20).random() * 10
|
|
val expectedMinutesTo = availableMinutes - ((1..3).random() * 10)
|
|
val expectedMinutesFrom = expectedMinutesTo - ((1..2).random() * 10)
|
|
val randomPrice = (0..40).random() * 500
|
|
val minParticipant = (1..10).random()
|
|
val maxParticipant = minParticipant + (1..10).random()
|
|
val createdBy = creatableAdminIds.random()
|
|
|
|
batchArgs.add(
|
|
arrayOf(
|
|
idGenerator.create(),
|
|
randomThemeName,
|
|
"$randomThemeName 설명이에요!!",
|
|
"https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRFiuCdwdz88l6pdfsRy1nFl0IHUVI7JMTQHg&s",
|
|
if (randomDay <= 30) true else false,
|
|
availableMinutes.toShort(),
|
|
expectedMinutesFrom.toShort(),
|
|
expectedMinutesTo.toShort(),
|
|
randomPrice,
|
|
Difficulty.entries.random().name,
|
|
minParticipant.toShort(),
|
|
maxParticipant.toShort(),
|
|
Timestamp.valueOf(randomCreatedAt),
|
|
createdBy,
|
|
Timestamp.valueOf(randomCreatedAt),
|
|
createdBy
|
|
)
|
|
)
|
|
}
|
|
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
|
jdbcTemplate.batchUpdate(sql, batchArgs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class UserDataInitializer : AbstractDataInitializer() {
|
|
val userCount = 1_000_000
|
|
|
|
init {
|
|
context("유저 초기 데이터 생성") {
|
|
test("$userCount 명의 회원 생성") {
|
|
val regions: List<String> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
|
|
entityManager.createQuery(
|
|
"SELECT r.code FROM RegionEntity r",
|
|
String::class.java
|
|
).resultList
|
|
}!!
|
|
|
|
val chunkSize = 10_000
|
|
val chunks = userCount / chunkSize
|
|
|
|
coroutineScope {
|
|
(1..chunks).map {
|
|
launch(Dispatchers.IO) {
|
|
processUsers(chunkSize, regions)
|
|
}
|
|
}.joinAll()
|
|
}
|
|
}
|
|
|
|
test("휴대폰 번호가 중복된 유저들에게 재배정") {
|
|
val duplicatePhoneUsers: List<UserEntity> =
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
|
|
entityManager.createQuery(
|
|
"""
|
|
SELECT u FROM UserEntity u
|
|
WHERE u.phone IN (
|
|
SELECT u2.phone FROM UserEntity u2
|
|
GROUP BY u2.phone
|
|
HAVING COUNT(u2.id) > 1
|
|
)
|
|
ORDER BY u.phone, u.id
|
|
""".trimIndent(),
|
|
UserEntity::class.java
|
|
).resultList
|
|
}!!
|
|
|
|
jdbcTemplate.execute("CREATE INDEX idx_users__phone ON users (phone)")
|
|
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
|
var currentPhone: String? = null
|
|
duplicatePhoneUsers.forEach { user ->
|
|
if (user.phone != currentPhone) {
|
|
currentPhone = user.phone
|
|
} else {
|
|
var newPhone: String
|
|
|
|
do {
|
|
newPhone = randomPhoneNumber()
|
|
val count: Long = entityManager.createQuery(
|
|
"SELECT COUNT(u.id) FROM UserEntity u WHERE u.phone = :phone",
|
|
Long::class.java
|
|
).setParameter("phone", newPhone)
|
|
.singleResult
|
|
|
|
if (count == 0L) break
|
|
} while (true)
|
|
|
|
user.phone = newPhone
|
|
user.updatedAt = Instant.now()
|
|
entityManager.merge(user)
|
|
}
|
|
}
|
|
}
|
|
|
|
jdbcTemplate.execute("DROP INDEX idx_users__phone ON users")
|
|
}
|
|
|
|
test("회원 상태 변경 이력 저장") {
|
|
val userId: List<Long> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
|
|
entityManager.createQuery(
|
|
"SELECT u.id FROM UserEntity u",
|
|
Long::class.java
|
|
).resultList
|
|
}!!
|
|
|
|
coroutineScope {
|
|
userId.chunked(10_000).map { chunk ->
|
|
launch(Dispatchers.IO) {
|
|
processStatus(chunk)
|
|
}
|
|
}.joinAll()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun processStatus(userIds: List<Long>) {
|
|
val sql = """
|
|
INSERT INTO user_status_history (
|
|
id, user_id, reason, status,
|
|
created_by, updated_by, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""".trimIndent()
|
|
val batchArgs = mutableListOf<Array<Any>>()
|
|
val now = LocalDateTime.now()
|
|
|
|
userIds.forEach { userId ->
|
|
batchArgs.add(
|
|
arrayOf(
|
|
idGenerator.create(),
|
|
userId,
|
|
SIGNUP,
|
|
UserStatus.ACTIVE.name,
|
|
userId,
|
|
userId,
|
|
Timestamp.valueOf(now),
|
|
Timestamp.valueOf(now)
|
|
)
|
|
)
|
|
}
|
|
|
|
executeBatch(sql, batchArgs).also { batchArgs.clear() }
|
|
}
|
|
|
|
private suspend fun processUsers(chunkSize: Int, regions: List<String>) {
|
|
val sql = """
|
|
INSERT INTO users (
|
|
id, name, email, password, phone, region_code, status,
|
|
created_by, updated_by, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""".trimIndent()
|
|
val batchArgs = mutableListOf<Array<Any>>()
|
|
|
|
repeat(chunkSize) {
|
|
val id: Long = idGenerator.create()
|
|
batchArgs.add(
|
|
arrayOf(
|
|
id,
|
|
randomKoreanName(),
|
|
"${randomString()}@sangdol.com",
|
|
randomString(),
|
|
randomPhoneNumber(),
|
|
regions.random(),
|
|
UserStatus.ACTIVE.name,
|
|
id,
|
|
id,
|
|
Timestamp.valueOf(LocalDateTime.now()),
|
|
Timestamp.valueOf(LocalDateTime.now())
|
|
)
|
|
)
|
|
if (batchArgs.size >= 1_000) {
|
|
executeBatch(sql, batchArgs).also { batchArgs.clear() }
|
|
}
|
|
}
|
|
|
|
if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() }
|
|
}
|
|
}
|
|
|
|
class ScheduleDataInitializer : AbstractDataInitializer() {
|
|
init {
|
|
context("일정 초기 데이터 생성") {
|
|
test("테마 생성일 기준으로 다음 3일차, 매일 최대 10개의 일정을 모든 매장에 생성") {
|
|
val stores: List<Pair<Long, Long>> = getStoreWithManagers()
|
|
val themes: List<ThemeEntity> = getThemes()
|
|
val maxScheduleCountPerDay = 10
|
|
val startTime = LocalTime.of(10, 0)
|
|
|
|
val themeWithTimes: Map<ThemeEntity, List<LocalTime>> = themes.associateWith { theme ->
|
|
val times = mutableListOf<LocalTime>()
|
|
val themeAvailableMinutes = theme.availableMinutes
|
|
var lastTime = startTime
|
|
|
|
while (times.size <= maxScheduleCountPerDay && lastTime.hour in (10..23)) {
|
|
times.add(lastTime)
|
|
lastTime = lastTime.plusMinutes(themeAvailableMinutes + 10L)
|
|
}
|
|
|
|
times
|
|
}
|
|
|
|
coroutineScope {
|
|
stores.map { store ->
|
|
launch(Dispatchers.IO) {
|
|
processTheme(store, themeWithTimes)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
test("내일 ~ 일주일 뒤 까지의 일정 생성") {
|
|
// val stores: List<Pair<Long, Long>> = getStoreWithManagers()
|
|
// val availableThemes: List<ThemeEntity> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
|
|
// entityManager.createQuery(
|
|
// "SELECT t FROM ThemeEntity t WHERE t.isActive = true AND t.createdAt >", ThemeEntity::class.java
|
|
// ).resultList
|
|
// }!!.take(10)
|
|
|
|
coroutineScope {
|
|
val jobs = (1..100).map { i ->
|
|
launch(Dispatchers.IO) {
|
|
val threadName = Thread.currentThread().name
|
|
println("[$i] 시작: $threadName")
|
|
delay(1)
|
|
println("[$i] 완료: $threadName")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun processTheme(
|
|
store: Pair<Long, Long>,
|
|
themeWithTimes: Map<ThemeEntity, List<LocalTime>>
|
|
) {
|
|
val sql = """
|
|
INSERT INTO schedule (
|
|
id, store_id, theme_id, date, time, status,
|
|
created_by, updated_by, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""".trimIndent()
|
|
|
|
val batchArgs = mutableListOf<Array<Any>>()
|
|
|
|
val status = ScheduleStatus.RESERVED.name
|
|
themeWithTimes.forEach { (theme, times) ->
|
|
val themeCreatedAt = theme.createdAt
|
|
(1..3).forEach {
|
|
val themeCreatedDateTime = themeCreatedAt.atZone(ZoneId.systemDefault())
|
|
val themeCreatedDate = themeCreatedDateTime.toLocalDate().plusDays(it.toLong())
|
|
val themeCreatedTime = themeCreatedDateTime.toLocalTime()
|
|
|
|
times.forEach { time ->
|
|
val storeId = store.first
|
|
val storeAdminId = store.second
|
|
batchArgs.add(
|
|
arrayOf(
|
|
idGenerator.create(),
|
|
storeId,
|
|
theme.id,
|
|
themeCreatedDate,
|
|
time,
|
|
status,
|
|
storeAdminId,
|
|
storeAdminId,
|
|
themeCreatedTime.plusHours(1),
|
|
themeCreatedTime.plusHours(1)
|
|
)
|
|
)
|
|
|
|
if (batchArgs.size >= 300) {
|
|
executeBatch(sql, batchArgs).also { batchArgs.clear() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (batchArgs.isNotEmpty()) {
|
|
executeBatch(sql, batchArgs).also { batchArgs.clear() }
|
|
}
|
|
}
|
|
|
|
private fun getStoreWithManagers(): List<Pair<Long, Long>> {
|
|
return transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
|
|
entityManager.createQuery(
|
|
"SELECT a.storeId, a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel = :permissionLevel",
|
|
List::class.java
|
|
).setParameter("type", AdminType.STORE)
|
|
.setParameter("permissionLevel", AdminPermissionLevel.FULL_ACCESS)
|
|
.resultList
|
|
}!!.map {
|
|
val array = it as List<*>
|
|
Pair(array[0] as Long, array[1] as Long)
|
|
}
|
|
}
|
|
|
|
private fun getThemes(): List<ThemeEntity> {
|
|
return transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
|
|
entityManager.createQuery(
|
|
"SELECT t FROM ThemeEntity t",
|
|
ThemeEntity::class.java
|
|
).resultList
|
|
}!!
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 아래의 ReservationDataInitializer 에서 사용할 임시 DTO 클래스
|
|
*/
|
|
data class ScheduleWithThemeParticipants(
|
|
val scheduleId: Long,
|
|
val themeMinParticipants: Short,
|
|
val themeMaxParticipants: Short,
|
|
)
|
|
|
|
class ReservationDataInitializer : AbstractDataInitializer() {
|
|
|
|
init {
|
|
context("예약 초기 데이터 생성") {
|
|
test("${ScheduleStatus.RESERVED}인 모든 일정에 예약을 1개씩 배정한다.") {
|
|
val chunkSize = 500
|
|
|
|
val chunkedSchedules: List<List<ScheduleWithThemeParticipants>> = entityManager.createQuery(
|
|
"SELECT new com.sangdol.roomescape.data.ScheduleWithThemeParticipants(s._id, t.minParticipants, t.maxParticipants) FROM ScheduleEntity s JOIN ThemeEntity t ON s.themeId = t.id WHERE s.status = :status",
|
|
ScheduleWithThemeParticipants::class.java
|
|
).setParameter("status", ScheduleStatus.RESERVED).resultList.chunked(chunkSize)
|
|
|
|
val chunkedUsers: List<List<UserContactResponse>> = entityManager.createQuery(
|
|
"SELECT new com.sangdol.roomescape.user.web.UserContactResponse(u._id, u.name, u.phone) FROM UserEntity u",
|
|
UserContactResponse::class.java
|
|
).resultList.chunked(chunkSize)
|
|
|
|
|
|
coroutineScope {
|
|
chunkedSchedules.forEachIndexed { idx, schedules ->
|
|
launch(Dispatchers.IO) {
|
|
processReservation(chunkedUsers[idx % chunkedUsers.size], schedules)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun processReservation(
|
|
users: List<UserContactResponse>,
|
|
schedules: List<ScheduleWithThemeParticipants>
|
|
) {
|
|
val sql = """
|
|
INSERT INTO reservation (
|
|
id, user_id, schedule_id,
|
|
reserver_name, reserver_contact, participant_count, requirement,
|
|
status, created_at, created_by, updated_at, updated_by
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""".trimIndent()
|
|
|
|
val batchArgs = mutableListOf<Array<Any>>()
|
|
|
|
val createdAt = LocalDateTime.now()
|
|
|
|
schedules.forEachIndexed { idx, schedule ->
|
|
val user: UserContactResponse = users[idx % users.size]
|
|
|
|
batchArgs.add(
|
|
arrayOf(
|
|
idGenerator.create(),
|
|
user.id,
|
|
schedule.scheduleId,
|
|
user.name,
|
|
user.phone,
|
|
(schedule.themeMinParticipants..schedule.themeMaxParticipants).random(),
|
|
randomKoreanWords(length = (20..100).random()),
|
|
ReservationStatus.CONFIRMED.name,
|
|
Timestamp.valueOf(createdAt),
|
|
user.id,
|
|
Timestamp.valueOf(createdAt),
|
|
user.id,
|
|
)
|
|
)
|
|
}
|
|
|
|
if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() }
|
|
}
|
|
}
|
|
|
|
class ReservationWithPrice(
|
|
themePrice: Int,
|
|
participantCount: Short,
|
|
|
|
val reservationId: Long,
|
|
val totalPrice: Int = (themePrice * participantCount),
|
|
)
|
|
|
|
data class PaymentWithMethods(
|
|
val id: Long,
|
|
val totalPrice: Int,
|
|
val method: PaymentMethod
|
|
)
|
|
|
|
class PaymentDataInitializer : AbstractDataInitializer() {
|
|
companion object {
|
|
val requestedAtCache: Timestamp = Timestamp.valueOf(KoreaDateTime.nowWithOffset().toLocalDateTime())
|
|
val approvedAtCache: Timestamp =
|
|
Timestamp.valueOf(KoreaDateTime.nowWithOffset().plusSeconds(5).toLocalDateTime())
|
|
val supportedPaymentMethods = listOf(PaymentMethod.TRANSFER, PaymentMethod.EASY_PAY, PaymentMethod.CARD)
|
|
val supportedCardType = listOf(CardType.CREDIT, CardType.CHECK)
|
|
|
|
val settlementStatus = "COMPLETED"
|
|
|
|
val paymentSql: String = """
|
|
INSERT INTO payment(
|
|
id, reservation_id, type, method,
|
|
payment_key, order_id, total_amount, status,
|
|
requested_at, approved_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""".trimIndent()
|
|
|
|
val paymentDetailSql: String = """
|
|
INSERT INTO payment_detail(
|
|
id, payment_id, supplied_amount, vat
|
|
) VALUES (?, ?, ?, ?)
|
|
""".trimIndent()
|
|
|
|
val paymentCardDetailSql: String = """
|
|
INSERT INTO payment_card_detail(
|
|
id, issuer_code, card_type, owner_type,
|
|
amount, card_number, approval_number, installment_plan_months,
|
|
is_interest_free, easypay_provider_code, easypay_discount_amount
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""".trimIndent()
|
|
|
|
val paymentEasypayPrepaidDetailSql: String = """
|
|
INSERT INTO payment_easypay_prepaid_detail(
|
|
id, easypay_provider_code, amount, discount_amount
|
|
) VALUES (?, ?, ?, ?)
|
|
""".trimIndent()
|
|
|
|
val paymentBankTransferDetailSql: String = """
|
|
INSERT INTO payment_bank_transfer_detail(
|
|
id, bank_code, settlement_status
|
|
) VALUES (?, ?, ?)
|
|
""".trimIndent()
|
|
}
|
|
|
|
init {
|
|
context("결제 데이터 초기화") {
|
|
test("모든 예약에 맞춰 1:1로 결제 및 결제 상세 데이터를 생성한다.") {
|
|
val allReservations: List<ReservationWithPrice> = entityManager.createQuery(
|
|
"SELECT t.price, r.participantCount, r._id FROM ReservationEntity r JOIN ScheduleEntity s ON s._id = r.scheduleId JOIN ThemeEntity t ON t.id = s.themeId",
|
|
List::class.java
|
|
).resultList.map {
|
|
val items = it as List<*>
|
|
ReservationWithPrice(
|
|
themePrice = items[0] as Int,
|
|
participantCount = items[1] as Short,
|
|
reservationId = items[2] as Long
|
|
)
|
|
}
|
|
|
|
coroutineScope {
|
|
allReservations.chunked(500).forEach { reservations ->
|
|
launch(Dispatchers.IO) {
|
|
processPaymentAndDefaultDetail(reservations)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
test("기존 결제 데이터에 상세 정보(계좌이체, 카드, 간편결제) 데이터를 생성한다.") {
|
|
val allPayments: List<PaymentWithMethods> = entityManager.createQuery(
|
|
"SELECT new com.sangdol.roomescape.data.PaymentWithMethods(pd._id, p.totalAmount, p.method) FROM PaymentEntity p JOIN PaymentDetailEntity pd ON p._id = pd.paymentId",
|
|
PaymentWithMethods::class.java
|
|
).resultList
|
|
|
|
coroutineScope {
|
|
allPayments.chunked(500).forEach { payments ->
|
|
launch(Dispatchers.IO) {
|
|
processPaymentDetail(payments)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
test("null 컴파일 에러를 피하기 위해 문자열 null로 임시 지정한 컬럼을 변경한다.") {
|
|
jdbcTemplate.execute("CREATE INDEX idx_payment_card_detail_easypay ON payment_card_detail (easypay_provider_code)")
|
|
|
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
|
jdbcTemplate.update(
|
|
"UPDATE payment_card_detail SET easypay_provider_code = ? WHERE easypay_provider_code = ?",
|
|
null,
|
|
"null"
|
|
)
|
|
}
|
|
|
|
jdbcTemplate.execute("DROP INDEX idx_payment_card_detail_easypay ON payment_card_detail")
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun processPaymentAndDefaultDetail(reservations: List<ReservationWithPrice>) {
|
|
val paymentBatchArgs = mutableListOf<Array<Any>>()
|
|
val detailBatchArgs = mutableListOf<Array<Any>>()
|
|
|
|
reservations.forEachIndexed { idx, reservations ->
|
|
val id = idGenerator.create()
|
|
val totalPrice = reservations.totalPrice
|
|
paymentBatchArgs.add(
|
|
arrayOf(
|
|
id,
|
|
reservations.reservationId,
|
|
PaymentType.NORMAL.name,
|
|
randomPaymentMethod(),
|
|
randomString(length = 64),
|
|
randomString(length = 20),
|
|
totalPrice,
|
|
PaymentStatus.DONE.name,
|
|
requestedAtCache,
|
|
approvedAtCache,
|
|
)
|
|
)
|
|
|
|
val suppliedAmount: Int = (totalPrice * 0.9).toInt()
|
|
val vat: Int = (totalPrice - suppliedAmount)
|
|
|
|
detailBatchArgs.add(
|
|
arrayOf(
|
|
idGenerator.create(),
|
|
id,
|
|
suppliedAmount,
|
|
vat
|
|
)
|
|
)
|
|
}
|
|
|
|
if (paymentBatchArgs.isNotEmpty()) {
|
|
executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() }
|
|
}
|
|
if (detailBatchArgs.isNotEmpty()) {
|
|
executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() }
|
|
}
|
|
}
|
|
|
|
private suspend fun processPaymentDetail(payments: List<PaymentWithMethods>) {
|
|
val transferBatchArgs = mutableListOf<Array<Any>>()
|
|
val cardBatchArgs = mutableListOf<Array<Any>>()
|
|
val easypayPrepaidBatchArgs = mutableListOf<Array<Any>>()
|
|
|
|
payments.forEach { payment ->
|
|
val totalPrice = payment.totalPrice
|
|
val randomDiscountAmount =
|
|
if (totalPrice < 100 || Math.random() < 0.8) 0 else ((100..totalPrice).random() / 100) * 100
|
|
val amount = totalPrice - randomDiscountAmount
|
|
|
|
when (payment.method) {
|
|
PaymentMethod.TRANSFER -> {
|
|
transferBatchArgs.add(
|
|
arrayOf(
|
|
payment.id,
|
|
BankCode.entries.random().name,
|
|
settlementStatus
|
|
)
|
|
)
|
|
}
|
|
|
|
PaymentMethod.EASY_PAY -> {
|
|
if (Math.random() <= 0.7) {
|
|
cardBatchArgs.add(
|
|
arrayOf(
|
|
payment.id,
|
|
CardIssuerCode.entries.random().name,
|
|
supportedCardType.random().name,
|
|
CardOwnerType.PERSONAL.name,
|
|
amount,
|
|
randomCardNumber(),
|
|
randomApprovalNumber(),
|
|
randomInstallmentPlanMonths(amount),
|
|
true,
|
|
EasyPayCompanyCode.entries.random().name,
|
|
randomDiscountAmount
|
|
)
|
|
)
|
|
|
|
} else {
|
|
easypayPrepaidBatchArgs.add(
|
|
arrayOf(
|
|
payment.id,
|
|
EasyPayCompanyCode.entries.random().name,
|
|
amount,
|
|
randomDiscountAmount,
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
PaymentMethod.CARD -> {
|
|
cardBatchArgs.add(
|
|
arrayOf(
|
|
payment.id,
|
|
CardIssuerCode.entries.random().name,
|
|
supportedCardType.random().name,
|
|
CardOwnerType.PERSONAL.name,
|
|
totalPrice,
|
|
randomCardNumber(),
|
|
randomApprovalNumber(),
|
|
randomInstallmentPlanMonths(totalPrice),
|
|
true,
|
|
"null",
|
|
0,
|
|
)
|
|
)
|
|
}
|
|
|
|
else -> return@forEach
|
|
}
|
|
}
|
|
if (transferBatchArgs.isNotEmpty()) {
|
|
executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() }
|
|
}
|
|
if (cardBatchArgs.isNotEmpty()) {
|
|
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
|
|
}
|
|
if (easypayPrepaidBatchArgs.isNotEmpty()) {
|
|
executeBatch(
|
|
paymentEasypayPrepaidDetailSql,
|
|
easypayPrepaidBatchArgs
|
|
).also { easypayPrepaidBatchArgs.clear() }
|
|
}
|
|
}
|
|
|
|
private suspend fun randomPaymentMethod(): String {
|
|
val random = Math.random()
|
|
|
|
return if (random <= 0.5) {
|
|
PaymentMethod.EASY_PAY.name
|
|
} else if (random <= 0.9) {
|
|
PaymentMethod.CARD.name
|
|
} else {
|
|
PaymentMethod.TRANSFER.name
|
|
}
|
|
}
|
|
|
|
private suspend fun randomCardNumber(): String {
|
|
return "${(10000000..99999999).random()}****${(100..999).random()}*"
|
|
}
|
|
|
|
private suspend fun randomApprovalNumber(): String {
|
|
return "${(10000000..99999999).random()}"
|
|
}
|
|
|
|
private suspend fun randomInstallmentPlanMonths(amount: Int): Int {
|
|
return if (amount < 50_000 || Math.random() < 0.9) {
|
|
0
|
|
} else {
|
|
(1..6).random()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun randomKoreanName(): String {
|
|
val lastNames = listOf(
|
|
"김", "이", "박", "최", "정", "강", "조", "윤", "장", "임",
|
|
"오", "서", "신", "권", "황", "안", "송", "류", "홍", "전", "고", "문", "양", "손", "배", "백", "허", "유", "남", "심", "노"
|
|
)
|
|
|
|
return "${lastNames.random()}${if (Math.random() < 0.1) randomKoreanWords(1) else randomKoreanWords(2)}"
|
|
}
|
|
|
|
fun randomKoreanWords(length: Int = 1): String {
|
|
val words = listOf(
|
|
"가", "나", "다", "라", "마", "바", "사", "아", "자", "차",
|
|
"카", "타", "파", "하", "강", "민", "서", "윤", "우", "진",
|
|
"현", "지", "은", "혜", "수", "영", "주", "원", "희", "경",
|
|
"선", "아", "나", "다", "라", "마", "바", "사", "아", "자",
|
|
"차", "카", "타", "파", "하",
|
|
)
|
|
|
|
return (1..length).joinToString("") { words.random() }
|
|
}
|