diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt index e7b6e54..390d75a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt @@ -39,7 +39,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(useCan.room, liveRoom) .innerJoin(liveRoom.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( useCan.isRefund.isFalse .and(useCan.createdAt.goe(startDate)) @@ -75,7 +78,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(order.audioContent, audioContent) .innerJoin(audioContent.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( order.createdAt.goe(startDate) .and(order.createdAt.loe(endDate)) @@ -142,7 +148,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(order.audioContent, audioContent) .innerJoin(audioContent.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where(order.isActive.isTrue) .groupBy( member.id, @@ -230,7 +239,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(creatorCommunity.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( useCan.isRefund.isFalse .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) @@ -251,7 +263,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(useCan.room, liveRoom) .innerJoin(liveRoom.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( useCan.isRefund.isFalse .and(useCan.createdAt.goe(startDate)) @@ -281,7 +296,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(useCan.room, liveRoom) .innerJoin(liveRoom.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( useCan.isRefund.isFalse .and(useCan.createdAt.goe(startDate)) @@ -301,7 +319,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(order.audioContent, audioContent) .innerJoin(audioContent.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( order.createdAt.goe(startDate) .and(order.createdAt.loe(endDate)) @@ -331,7 +352,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(order.audioContent, audioContent) .innerJoin(audioContent.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( order.createdAt.goe(startDate) .and(order.createdAt.loe(endDate)) @@ -351,7 +375,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(creatorCommunity.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( useCan.isRefund.isFalse .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) @@ -382,7 +409,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(creatorCommunity.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( useCan.isRefund.isFalse .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatio.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatio.kt index 33ce414..ade5326 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatio.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatio.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.member.Member +import java.time.LocalDateTime import javax.persistence.Entity import javax.persistence.FetchType import javax.persistence.JoinColumn @@ -9,12 +10,29 @@ import javax.persistence.OneToOne @Entity data class CreatorSettlementRatio( - val subsidy: Int, - val liveSettlementRatio: Int, - val contentSettlementRatio: Int, - val communitySettlementRatio: Int + var subsidy: Int, + var liveSettlementRatio: Int, + var contentSettlementRatio: Int, + var communitySettlementRatio: Int ) : BaseEntity() { @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) var member: Member? = null + + var deletedAt: LocalDateTime? = null + + fun softDelete() { + this.deletedAt = LocalDateTime.now() + } + + fun restore() { + this.deletedAt = null + } + + fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) { + this.subsidy = subsidy + this.liveSettlementRatio = live + this.contentSettlementRatio = content + this.communitySettlementRatio = community + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioController.kt index 10cd41e..658f373 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioController.kt @@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse import org.springframework.data.domain.Pageable import org.springframework.security.access.prepost.PreAuthorize 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.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -27,4 +28,14 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat limit = pageable.pageSize.toLong() ) ) + + @PostMapping("/update") + fun updateCreatorSettlementRatio( + @RequestBody request: CreateCreatorSettlementRatioRequest + ) = ApiResponse.ok(service.updateCreatorSettlementRatio(request)) + + @PostMapping("/delete/{memberId}") + fun deleteCreatorSettlementRatio( + @PathVariable memberId: Long + ) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioRepository.kt index e788f53..b19f015 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioRepository.kt @@ -7,7 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository interface CreatorSettlementRatioRepository : JpaRepository, - CreatorSettlementRatioQueryRepository + CreatorSettlementRatioQueryRepository { + fun findByMemberId(memberId: Long): CreatorSettlementRatio? +} interface CreatorSettlementRatioQueryRepository { fun getCreatorSettlementRatio(offset: Long, limit: Long): List @@ -21,6 +23,7 @@ class CreatorSettlementRatioQueryRepositoryImpl( return queryFactory .select( QGetCreatorSettlementRatioItem( + member.id, member.nickname, creatorSettlementRatio.subsidy, creatorSettlementRatio.liveSettlementRatio, @@ -30,6 +33,7 @@ class CreatorSettlementRatioQueryRepositoryImpl( ) .from(creatorSettlementRatio) .innerJoin(creatorSettlementRatio.member, member) + .where(creatorSettlementRatio.deletedAt.isNull) .orderBy(creatorSettlementRatio.id.asc()) .offset(offset) .limit(limit) @@ -40,6 +44,7 @@ class CreatorSettlementRatioQueryRepositoryImpl( return queryFactory .select(creatorSettlementRatio.id) .from(creatorSettlementRatio) + .where(creatorSettlementRatio.deletedAt.isNull) .fetch() .size } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioService.kt index 140fb4a..d114f8c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioService.kt @@ -14,8 +14,6 @@ class CreatorSettlementRatioService( ) { @Transactional fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { - val creatorSettlementRatio = request.toEntity() - val creator = memberRepository.findByIdOrNull(request.memberId) ?: throw SodaException("잘못된 크리에이터 입니다.") @@ -23,10 +21,52 @@ class CreatorSettlementRatioService( throw SodaException("잘못된 크리에이터 입니다.") } + val existing = repository.findByMemberId(request.memberId) + if (existing != null) { + // revive if soft-deleted, then update values + existing.restore() + existing.updateValues( + request.subsidy, + request.liveSettlementRatio, + request.contentSettlementRatio, + request.communitySettlementRatio + ) + repository.save(existing) + return + } + + val creatorSettlementRatio = request.toEntity() creatorSettlementRatio.member = creator repository.save(creatorSettlementRatio) } + @Transactional + fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { + val creator = memberRepository.findByIdOrNull(request.memberId) + ?: throw SodaException("잘못된 크리에이터 입니다.") + if (creator.role != MemberRole.CREATOR) { + throw SodaException("잘못된 크리에이터 입니다.") + } + val existing = repository.findByMemberId(request.memberId) + ?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.") + existing.restore() + existing.updateValues( + request.subsidy, + request.liveSettlementRatio, + request.contentSettlementRatio, + request.communitySettlementRatio + ) + repository.save(existing) + } + + @Transactional + fun deleteCreatorSettlementRatio(memberId: Long) { + val existing = repository.findByMemberId(memberId) + ?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.") + existing.softDelete() + repository.save(existing) + } + @Transactional(readOnly = true) fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse { val totalCount = repository.getCreatorSettlementRatioTotalCount() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/GetCreatorSettlementRatioResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/GetCreatorSettlementRatioResponse.kt index 24a4d96..b6603ff 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/GetCreatorSettlementRatioResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/GetCreatorSettlementRatioResponse.kt @@ -8,6 +8,7 @@ data class GetCreatorSettlementRatioResponse( ) data class GetCreatorSettlementRatioItem @QueryProjection constructor( + val memberId: Long, val nickname: String, val subsidy: Int, val liveSettlementRatio: Int, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanController.kt index 042eb29..0a79c94 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanController.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.admin.can +import kr.co.vividnext.sodalive.can.CanResponse import kr.co.vividnext.sodalive.common.ApiResponse import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.DeleteMapping +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.RequestBody @@ -13,6 +15,11 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/admin/can") @PreAuthorize("hasRole('ADMIN')") class AdminCanController(private val service: AdminCanService) { + @GetMapping + fun getCans(): ApiResponse> { + return ApiResponse.ok(service.getCans()) + } + @PostMapping fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRepository.kt index e784d13..e9c0793 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRepository.kt @@ -1,6 +1,38 @@ package kr.co.vividnext.sodalive.admin.can +import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.can.Can +import kr.co.vividnext.sodalive.can.CanResponse +import kr.co.vividnext.sodalive.can.CanStatus +import kr.co.vividnext.sodalive.can.QCan.can1 +import kr.co.vividnext.sodalive.can.QCanResponse import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository -interface AdminCanRepository : JpaRepository +interface AdminCanRepository : JpaRepository, AdminCanQueryRepository + +interface AdminCanQueryRepository { + fun findAllByStatus(status: CanStatus): List +} + +@Repository +class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository { + override fun findAllByStatus(status: CanStatus): List { + return queryFactory + .select( + QCanResponse( + can1.id, + can1.title, + can1.can, + can1.rewardCan, + can1.price.intValue(), + can1.currency, + can1.price.stringValue() + ) + ) + .from(can1) + .where(can1.status.eq(status)) + .orderBy(can1.currency.asc(), can1.price.asc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRequest.kt index 92258ff..83bda60 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRequest.kt @@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.admin.can import kr.co.vividnext.sodalive.can.Can import kr.co.vividnext.sodalive.can.CanStatus import kr.co.vividnext.sodalive.extensions.moneyFormat +import java.math.BigDecimal data class AdminCanRequest( val can: Int, val rewardCan: Int, - val price: Int + val price: BigDecimal, + val currency: String ) { fun toEntity(): Can { var title = "${can.moneyFormat()} 캔" @@ -20,6 +22,7 @@ data class AdminCanRequest( can = can, rewardCan = rewardCan, price = price, + currency = currency, status = CanStatus.SALE ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt index 786138b..612e414 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.admin.can import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository +import kr.co.vividnext.sodalive.can.CanResponse import kr.co.vividnext.sodalive.can.CanStatus import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.can.charge.ChargeRepository @@ -20,6 +21,10 @@ class AdminCanService( private val chargeRepository: ChargeRepository, private val memberRepository: AdminMemberRepository ) { + fun getCans(): List { + return repository.findAllByStatus(status = CanStatus.SALE) + } + @Transactional fun saveCan(request: AdminCanRequest) { repository.save(request.toEntity()) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusService.kt index fef1205..cc6d4ca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusService.kt @@ -20,16 +20,15 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository) .withZoneSameInstant(ZoneId.of("UTC")) .toLocalDateTime() - var totalChargeAmount = 0 + var totalChargeAmount = 0.toBigDecimal() var totalChargeCount = 0L val chargeStatusList = repository.getChargeStatus(startDate, endDate) - .asSequence() .map { val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) { it.pgChargeAmount } else { - it.appleChargeAmount.toInt() + it.appleChargeAmount } val chargeCount = it.chargeCount diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailQueryDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailQueryDto.kt index d4b5e91..3164e91 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailQueryDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailQueryDto.kt @@ -1,13 +1,14 @@ package kr.co.vividnext.sodalive.admin.charge import com.querydsl.core.annotations.QueryProjection +import java.math.BigDecimal data class GetChargeStatusDetailQueryDto @QueryProjection constructor( val memberId: Long, val nickname: String, val method: String, - val appleChargeAmount: Double, - val pgChargeAmount: Int, + val appleChargeAmount: BigDecimal, + val pgChargeAmount: BigDecimal, val locale: String, val datetime: String ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusQueryDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusQueryDto.kt index c5c16d0..5d4c6a4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusQueryDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusQueryDto.kt @@ -2,11 +2,12 @@ package kr.co.vividnext.sodalive.admin.charge import com.querydsl.core.annotations.QueryProjection import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import java.math.BigDecimal data class GetChargeStatusQueryDto @QueryProjection constructor( val date: String, - val appleChargeAmount: Double, - val pgChargeAmount: Int, + val appleChargeAmount: BigDecimal, + val pgChargeAmount: BigDecimal, val chargeCount: Long, val paymentGateWay: PaymentGateway ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusResponse.kt index efbe3aa..75dffcb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusResponse.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.admin.charge +import java.math.BigDecimal + data class GetChargeStatusResponse( val date: String, - val chargeAmount: Int, + val chargeAmount: BigDecimal, val chargeCount: Long, val pg: String ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/ad/AdminAdStatisticsRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/ad/AdminAdStatisticsRepository.kt index 4485152..f7f55ce 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/ad/AdminAdStatisticsRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/ad/AdminAdStatisticsRepository.kt @@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.admin.statistics.ad import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.core.types.dsl.DateTimePath import com.querydsl.core.types.dsl.Expressions -import com.querydsl.core.types.dsl.NumberExpression import com.querydsl.core.types.dsl.StringTemplate import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType @@ -67,7 +66,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { val firstPaymentTotalAmount = CaseBuilder() .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)) .then(adTrackingHistory.price) - .otherwise(Expressions.constant(0.0)) + .otherwise(0.toBigDecimal()) .sum() val repeatPaymentCount = CaseBuilder() @@ -79,7 +78,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { val repeatPaymentTotalAmount = CaseBuilder() .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) .then(adTrackingHistory.price) - .otherwise(Expressions.constant(0.0)) + .otherwise(0.toBigDecimal()) .sum() val allPaymentCount = CaseBuilder() @@ -97,7 +96,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { .or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) ) .then(adTrackingHistory.price) - .otherwise(Expressions.constant(0.0)) + .otherwise(0.toBigDecimal()) .sum() return queryFactory @@ -111,11 +110,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { loginCount, signUpCount, firstPaymentCount, - roundedValueDecimalPlaces2(firstPaymentTotalAmount), + firstPaymentTotalAmount, repeatPaymentCount, - roundedValueDecimalPlaces2(repeatPaymentTotalAmount), + repeatPaymentTotalAmount, allPaymentCount, - roundedValueDecimalPlaces2(allPaymentTotalAmount) + allPaymentTotalAmount ) ) .from(adTrackingHistory) @@ -148,13 +147,4 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { "%Y-%m-%d" ) } - - private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression): NumberExpression { - return Expressions.numberTemplate( - Double::class.java, - "ROUND({0}, {1})", - valueExpression, - 2 - ) - } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/ad/GetAdminAdStatisticsResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/ad/GetAdminAdStatisticsResponse.kt index 2c9627b..b968e3e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/ad/GetAdminAdStatisticsResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/ad/GetAdminAdStatisticsResponse.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.admin.statistics.ad import com.querydsl.core.annotations.QueryProjection +import java.math.BigDecimal data class GetAdminAdStatisticsResponse( val totalCount: Int, @@ -16,9 +17,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor( val loginCount: Int, val signUpCount: Int, val firstPaymentCount: Int, - val firstPaymentTotalAmount: Double, + val firstPaymentTotalAmount: BigDecimal, val repeatPaymentCount: Int, - val repeatPaymentTotalAmount: Double, + val repeatPaymentTotalAmount: BigDecimal, val allPaymentCount: Int, - val allPaymentTotalAmount: Double + val allPaymentTotalAmount: BigDecimal ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt index 1f0f4f6..ec8f887 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.can import kr.co.vividnext.sodalive.common.BaseEntity +import java.math.BigDecimal +import javax.persistence.Column import javax.persistence.Entity import javax.persistence.EnumType import javax.persistence.Enumerated @@ -10,7 +12,10 @@ data class Can( var title: String, var can: Int, var rewardCan: Int, - var price: Int, + @Column(precision = 10, scale = 4, nullable = false) + var price: BigDecimal, + @Column(length = 3, nullable = false, columnDefinition = "CHAR(3)") + var currency: String, @Enumerated(value = EnumType.STRING) var status: CanStatus ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt index af53b78..e002aba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.can import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.GeoCountry import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.Pageable @@ -9,13 +10,15 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import javax.servlet.http.HttpServletRequest @RestController @RequestMapping("/can") class CanController(private val service: CanService) { @GetMapping - fun getCans(): ApiResponse> { - return ApiResponse.ok(service.getCans()) + fun getCans(request: HttpServletRequest): ApiResponse> { + val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER + return ApiResponse.ok(service.getCans(geoCountry)) } @GetMapping("/status") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index 61863a0..fcc3f2f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository interface CanRepository : JpaRepository, CanQueryRepository interface CanQueryRepository { - fun findAllByStatus(status: CanStatus): List + fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List fun getCanUseStatus(member: Member, pageable: Pageable): List fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? @@ -32,7 +32,7 @@ interface CanQueryRepository { @Repository class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository { - override fun findAllByStatus(status: CanStatus): List { + override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List { return queryFactory .select( QCanResponse( @@ -40,11 +40,16 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue can1.title, can1.can, can1.rewardCan, - can1.price + can1.price.intValue(), + can1.currency, + can1.price.stringValue() ) ) .from(can1) - .where(can1.status.eq(status)) + .where( + can1.status.eq(status), + can1.currency.eq(currency) + ) .orderBy(can1.can.asc()) .fetch() } @@ -64,11 +69,13 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue val chargeStatusCondition = when (container) { "aos" -> { charge.payment.paymentGateway.eq(PaymentGateway.PG) + .or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE)) .or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP)) } "ios" -> { charge.payment.paymentGateway.eq(PaymentGateway.PG) + .or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE)) .or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt index e3c5d48..ffc5d91 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt @@ -7,5 +7,7 @@ data class CanResponse @QueryProjection constructor( val title: String, val can: Int, val rewardCan: Int, - val price: Int + val price: Int, + val currency: String, + val priceStr: String ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index 555cc6e..27ef531 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can import kr.co.vividnext.sodalive.can.charge.ChargeStatus import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.GeoCountry import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @@ -11,8 +12,12 @@ import java.time.format.DateTimeFormatter @Service class CanService(private val repository: CanRepository) { - fun getCans(): List { - return repository.findAllByStatus(status = CanStatus.SALE) + fun getCans(geoCountry: GeoCountry): List { + val currency = when (geoCountry) { + GeoCountry.KR -> "KRW" + else -> "USD" + } + return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency) } fun getCanStatus(member: Member, container: String): GetCanStatusResponse { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt index 066cd8f..8083f13 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt @@ -1,7 +1,9 @@ package kr.co.vividnext.sodalive.can.charge +import java.math.BigDecimal + data class ChargeCompleteResponse( - val price: Double, + val price: BigDecimal, val currencyCode: String, val isFirstCharged: Boolean ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt index 3748c96..73948f3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt @@ -6,20 +6,77 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType import kr.co.vividnext.sodalive.marketing.AdTrackingService import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException import java.time.LocalDateTime +import javax.servlet.http.HttpServletRequest @RestController @RequestMapping("/charge") class ChargeController( private val service: ChargeService, - private val trackingService: AdTrackingService + private val trackingService: AdTrackingService, + + @Value("\${payverse.inbound-ip}") + private val payverseInboundIp: String ) { + @PostMapping("/payverse") + fun payverseCharge( + @RequestBody request: PayverseChargeRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.payverseCharge(member, request)) + } + + @PostMapping("/payverse/verify") + fun payverseVerify( + @RequestBody verifyRequest: PayverseVerifyRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + val response = service.payverseVerify(memberId = member.id!!, verifyRequest) + trackingCharge(member, response) + ApiResponse.ok(Unit) + } + + // Payverse Webhook 엔드포인트 (payverseVerify 아래) + @PostMapping("/payverse/webhook") + fun payverseWebhook( + @RequestBody request: PayverseWebhookRequest, + servletRequest: HttpServletRequest + ): PayverseWebhookResponse { + val header = servletRequest.getHeader("X-Forwarded-For") + val remoteIp = if (header.isNullOrEmpty()) { + servletRequest.remoteAddr + } else { + header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP + } + + if (remoteIp != payverseInboundIp) { + throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + val success = service.payverseWebhook(request) + if (!success) { + throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + return PayverseWebhookResponse(receiveResult = "SUCCESS") + } + @PostMapping fun charge( @RequestBody chargeRequest: ChargeRequest, @@ -111,8 +168,7 @@ class ChargeController( memberId = member.id!!, chargeId = chargeId, productId = request.productId, - purchaseToken = request.purchaseToken, - paymentGateway = request.paymentGateway + purchaseToken = request.purchaseToken ) trackingCharge(member, response) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt index 2fc7a14..b3917e6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.charge import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import java.math.BigDecimal data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway) @@ -20,14 +21,14 @@ data class VerifyResult( val method: String, val pg: String, val status: Int, - val price: Int + val price: BigDecimal ) data class AppleChargeRequest( val title: String, val chargeCan: Int, val paymentGateway: PaymentGateway, - var price: Double? = null, + var price: BigDecimal? = null, var locale: String? = null ) @@ -38,9 +39,53 @@ data class AppleVerifyResponse(val status: Int) data class GoogleChargeRequest( val title: String, val chargeCan: Int, - val price: Double, + val price: BigDecimal, val currencyCode: String, val productId: String, val purchaseToken: String, val paymentGateway: PaymentGateway ) + +data class PayverseChargeRequest( + val canId: Long +) + +data class PayverseChargeResponse( + val chargeId: Long, + val payloadJson: String +) + +data class PayverseVerifyRequest( + val transactionId: String, + val orderId: String +) + +data class PayverseVerifyResponse( + val resultStatus: String, + val tid: String, + val schemeGroup: String, + val schemeCode: String, + val transactionType: String, + val transactionStatus: String, + val transactionMessage: String, + val orderId: String, + val customerId: String, + val requestCurrency: String, + val requestAmount: BigDecimal +) + +data class PayverseWebhookRequest( + val type: String, + val mid: String, + val tid: String, + val schemeGroup: String, + val schemeCode: String, + val orderId: String, + val requestCurrency: String, + val requestAmount: BigDecimal, + val resultStatus: String, + val approvalDay: String, + val sign: String +) + +data class PayverseWebhookResponse(val receiveResult: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt index dfc7a4b..2597d3d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -22,6 +22,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import org.apache.commons.codec.digest.DigestUtils import org.json.JSONObject import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher @@ -34,6 +35,7 @@ import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.math.RoundingMode import java.time.LocalDateTime +import java.time.format.DateTimeFormatter @Service @Transactional(readOnly = true) @@ -63,9 +65,112 @@ class ChargeService( @Value("\${apple.iap-verify-sandbox-url}") private val appleInAppVerifySandBoxUrl: String, @Value("\${apple.iap-verify-url}") - private val appleInAppVerifyUrl: String + private val appleInAppVerifyUrl: String, + + @Value("\${payverse.mid}") + private val payverseMid: String, + @Value("\${payverse.client-key}") + private val payverseClientKey: String, + @Value("\${payverse.secret-key}") + private val payverseSecretKey: String, + + @Value("\${payverse.usd-mid}") + private val payverseUsdMid: String, + @Value("\${payverse.usd-client-key}") + private val payverseUsdClientKey: String, + @Value("\${payverse.usd-secret-key}") + private val payverseUsdSecretKey: String, + + @Value("\${payverse.host}") + private val payverseHost: String, + + @Value("\${server.env}") + private val serverEnv: String ) { + @Transactional + fun payverseWebhook(request: PayverseWebhookRequest): Boolean { + val chargeId = request.orderId.toLongOrNull() ?: return false + val charge = chargeRepository.findByIdOrNull(chargeId) ?: return false + + // 결제수단 확인 + if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) { + return false + } + + // 결제 상태 분기 처리 + return when (charge.payment?.status) { + PaymentStatus.REQUEST -> { + // 성공 조건 검증 + val mid = if (request.requestCurrency == "KRW") { + payverseMid + } else { + payverseUsdMid + } + val expectedSign = DigestUtils.sha512Hex( + String.format( + "||%s||%s||%s||%s||%s||", + if (request.requestCurrency == "KRW") { + payverseSecretKey + } else { + payverseUsdSecretKey + }, + mid, + request.orderId, + request.requestAmount, + request.approvalDay + ) + ) + + val isAmountMatch = request.requestAmount.compareTo( + charge.payment!!.price + ) == 0 + + val isSuccess = request.resultStatus == "SUCCESS" && + request.mid == mid && + request.orderId.toLongOrNull() == charge.id && + isAmountMatch && + request.sign == expectedSign + + if (isSuccess) { + // payverseVerify의 226~246 라인과 동일 처리 + charge.payment?.receiptId = request.tid + val mappedMethod = if (request.schemeGroup == "PVKR") { + mapPayverseSchemeToMethodByCode(request.schemeCode) + } else { + null + } + charge.payment?.method = mappedMethod ?: request.schemeCode + charge.payment?.status = PaymentStatus.COMPLETE + charge.payment?.locale = request.requestCurrency + + val member = charge.member!! + member.charge(charge.chargeCan, charge.rewardCan, "pg") + + applicationEventPublisher.publishEvent( + ChargeSpringEvent( + chargeId = charge.id!!, + memberId = member.id!! + ) + ) + true + } else { + false + } + } + + PaymentStatus.COMPLETE -> { + // 이미 결제가 완료된 경우 성공 처리(idempotent) + true + } + + else -> { + // 그 외 상태는 404 + false + } + } + } + @Transactional fun chargeByCoupon(couponNumber: String, member: Member): String { val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber) @@ -126,6 +231,177 @@ class ChargeService( } } + @Transactional + fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse { + val can = canRepository.findByIdOrNull(request.canId) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + val requestCurrency = can.currency + val isKrw = requestCurrency == "KRW" + val mid = if (isKrw) { + payverseMid + } else { + payverseUsdMid + } + val clientKey = if (isKrw) { + payverseClientKey + } else { + payverseUsdClientKey + } + val secretKey = if (isKrw) { + payverseSecretKey + } else { + payverseUsdSecretKey + } + + val charge = Charge(can.can, can.rewardCan) + charge.title = can.title + charge.member = member + charge.can = can + + val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE) + payment.price = can.price + charge.payment = payment + + val savedCharge = chargeRepository.save(charge) + + val chargeId = savedCharge.id!! + val amount = BigDecimal( + savedCharge.payment!!.price + .setScale(4, RoundingMode.HALF_UP) + .stripTrailingZeros() + .toPlainString() + ) + val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + val sign = DigestUtils.sha512Hex( + String.format( + "||%s||%s||%s||%s||%s||", + secretKey, + mid, + chargeId, + amount, + reqDate + ) + ) + val customerId = "${serverEnv}_user_${member.id!!}" + + val payload = linkedMapOf( + "mid" to mid, + "clientKey" to clientKey, + "orderId" to chargeId.toString(), + "customerId" to customerId, + "productName" to can.title, + "requestCurrency" to requestCurrency, + "requestAmount" to amount, + "reqDate" to reqDate, + "sign" to sign + ) + val payloadJson = objectMapper.writeValueAsString(payload) + + return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson) + } + + @Transactional + fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse { + val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) + ?: throw SodaException("결제정보에 오류가 있습니다.") + val member = memberRepository.findByIdOrNull(memberId) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + val isKrw = charge.can?.currency == "KRW" + val mid = if (isKrw) { + payverseMid + } else { + payverseUsdMid + } + val clientKey = if (isKrw) { + payverseClientKey + } else { + payverseUsdClientKey + } + + // 결제수단 확인 + if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) { + throw SodaException("결제정보에 오류가 있습니다.") + } + + // 결제 상태에 따른 분기 처리 + when (charge.payment?.status) { + PaymentStatus.REQUEST -> { + try { + val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}" + val request = Request.Builder() + .url(url) + .addHeader("mid", mid) + .addHeader("clientKey", clientKey) + .get() + .build() + + val response = okHttpClient.newCall(request).execute() + if (!response.isSuccessful) { + throw SodaException("결제정보에 오류가 있습니다.") + } + + val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.") + val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java) + + val customerId = "${serverEnv}_user_${member.id!!}" + val isSuccess = verifyResponse.resultStatus == "SUCCESS" && + verifyResponse.transactionStatus == "SUCCESS" && + verifyResponse.orderId.toLongOrNull() == charge.id && + verifyResponse.customerId == customerId && + verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0 + + if (isSuccess) { + // verify 함수의 232~248 라인과 동일 처리 + charge.payment?.receiptId = verifyResponse.tid + val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") { + mapPayverseSchemeToMethodByCode(verifyResponse.schemeCode) + } else { + null + } + charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode + charge.payment?.status = PaymentStatus.COMPLETE + // 통화코드 설정 + charge.payment?.locale = verifyResponse.requestCurrency + + member.charge(charge.chargeCan, charge.rewardCan, "pg") + + applicationEventPublisher.publishEvent( + ChargeSpringEvent( + chargeId = charge.id!!, + memberId = member.id!! + ) + ) + + return ChargeCompleteResponse( + price = charge.payment!!.price, + currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", + isFirstCharged = chargeRepository.isFirstCharged(memberId) + ) + } else { + throw SodaException("결제정보에 오류가 있습니다.") + } + } catch (_: Exception) { + throw SodaException("결제정보에 오류가 있습니다.") + } + } + + PaymentStatus.COMPLETE -> { + // 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환 + return ChargeCompleteResponse( + price = charge.payment!!.price, + currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", + isFirstCharged = chargeRepository.isFirstCharged(memberId) + ) + } + + else -> { + throw SodaException("결제정보에 오류가 있습니다.") + } + } + } + @Transactional fun charge(member: Member, request: ChargeRequest): ChargeResponse { val can = canRepository.findByIdOrNull(request.canId) @@ -137,7 +413,7 @@ class ChargeService( charge.can = can val payment = Payment(paymentGateway = request.paymentGateway) - payment.price = can.price.toDouble() + payment.price = can.price charge.payment = payment chargeRepository.save(charge) @@ -176,14 +452,14 @@ class ChargeService( ) return ChargeCompleteResponse( - price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + price = charge.payment!!.price, currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", isFirstCharged = chargeRepository.isFirstCharged(memberId) ) } else { throw SodaException("결제정보에 오류가 있습니다.") } - } catch (e: Exception) { + } catch (_: Exception) { throw SodaException("결제정보에 오류가 있습니다.") } } else { @@ -226,14 +502,14 @@ class ChargeService( ) return ChargeCompleteResponse( - price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + price = charge.payment!!.price, currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", isFirstCharged = chargeRepository.isFirstCharged(memberId) ) } else { throw SodaException("결제정보에 오류가 있습니다.") } - } catch (e: Exception) { + } catch (_: Exception) { throw SodaException("결제정보에 오류가 있습니다.") } } else { @@ -251,7 +527,7 @@ class ChargeService( payment.price = if (request.price != null) { request.price!! } else { - 0.toDouble() + 0.toBigDecimal() } payment.locale = request.locale @@ -286,7 +562,7 @@ class ChargeService( ) return ChargeCompleteResponse( - price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + price = charge.payment!!.price, currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", isFirstCharged = chargeRepository.isFirstCharged(memberId) ) @@ -303,7 +579,7 @@ class ChargeService( member: Member, title: String, chargeCan: Int, - price: Double, + price: BigDecimal, currencyCode: String, productId: String, purchaseToken: String, @@ -331,8 +607,7 @@ class ChargeService( memberId: Long, chargeId: Long, productId: String, - purchaseToken: String, - paymentGateway: PaymentGateway + purchaseToken: String ): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(id = chargeId) ?: throw SodaException("결제정보에 오류가 있습니다.") @@ -354,7 +629,7 @@ class ChargeService( ) return ChargeCompleteResponse( - price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + price = charge.payment!!.price, currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", isFirstCharged = chargeRepository.isFirstCharged(memberId) ) @@ -436,4 +711,13 @@ class ChargeService( throw SodaException("결제를 완료하지 못했습니다.") } } + + // Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환 + private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? { + val cardCodes = setOf( + "041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381", + "218", "071", "002", "089", "045", "050", "048", "090", "092" + ) + return if (schemeCode != null && cardCodes.contains(schemeCode)) "카드" else null + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempRequest.kt index d24f907..5afdfa8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempRequest.kt @@ -1,9 +1,10 @@ package kr.co.vividnext.sodalive.can.charge.temp import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import java.math.BigDecimal data class ChargeTempRequest( val can: Int, - val price: Int, + val price: BigDecimal, val paymentGateway: PaymentGateway ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt index 8e2e645..3f2f1aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt @@ -41,7 +41,7 @@ class ChargeTempService( charge.member = member val payment = Payment(paymentGateway = request.paymentGateway) - payment.price = request.price.toDouble() + payment.price = request.price charge.payment = payment chargeRepository.save(charge) @@ -66,7 +66,7 @@ class ChargeTempService( VerifyResult::class.java ) - if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) { + if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) { charge.payment?.receiptId = verifyResult.receiptId charge.payment?.method = verifyResult.method charge.payment?.status = PaymentStatus.COMPLETE @@ -74,7 +74,7 @@ class ChargeTempService( } else { throw SodaException("결제정보에 오류가 있습니다.") } - } catch (e: Exception) { + } catch (_: Exception) { throw SodaException("결제정보에 오류가 있습니다.") } } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt index 2c0c527..c146458 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.payment import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.common.BaseEntity +import java.math.BigDecimal import javax.persistence.Column import javax.persistence.Entity import javax.persistence.EnumType @@ -25,7 +26,8 @@ data class Payment( var receiptId: String? = null var method: String? = null - var price: Double = 0.toDouble() + @Column(precision = 10, scale = 4, nullable = false) + var price: BigDecimal = 0.toBigDecimal() var locale: String? = null var orderId: String? = null } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt index a37459d..b5b414a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt @@ -1,5 +1,5 @@ package kr.co.vividnext.sodalive.can.payment enum class PaymentGateway { - PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD + PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index 62ccf19..43ad355 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.chat.original.controller import kr.co.vividnext.sodalive.chat.character.dto.Character -import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkCharactersPageResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse @@ -79,37 +78,4 @@ class OriginalWorkController( val response = OriginalWorkDetailResponse.from(ow, imageHost, characters) ApiResponse.ok(response) } - - /** - * 지정 원작에 속한 활성 캐릭터 목록 조회 (페이징) - * - 로그인 및 본인인증 필수 - * - 기본 페이지 사이즈 20 - */ - @GetMapping("/{id}/characters") - fun listCharacters( - @PathVariable id: Long, - @RequestParam(defaultValue = "0") page: Int, - @RequestParam(defaultValue = "20") size: Int, - @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? - ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - - val pageRes = queryService.getActiveCharactersPage(id, page, size) - val content = pageRes.content.map { - val path = it.imagePath ?: "profile/default-profile.png" - Character( - characterId = it.id!!, - name = it.name, - description = it.description, - imageUrl = "$imageHost/$path" - ) - } - ApiResponse.ok( - OriginalWorkCharactersPageResponse( - totalCount = pageRes.totalElements, - content = content - ) - ) - } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/GeoCountry.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/GeoCountry.kt new file mode 100644 index 0000000..be44f32 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/GeoCountry.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.common + +const val WAF_GEO_HEADER = "x-amzn-waf-geo-country" + +enum class GeoCountry { KR, OTHER } + +fun parseGeo(headerValue: String?): GeoCountry = + if (headerValue?.trim()?.uppercase() == "KR") GeoCountry.KR else GeoCountry.OTHER diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/GeoCountryFilter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/GeoCountryFilter.kt new file mode 100644 index 0000000..1981f60 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/GeoCountryFilter.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.common + +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Component +class GeoCountryFilter : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val country = parseGeo(request.getHeader(WAF_GEO_HEADER)) + request.setAttribute("geoCountry", country) + filterChain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt index 045cf83..74e1396 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.multipart.MaxUploadSizeExceededException +import org.springframework.web.server.ResponseStatusException @RestControllerAdvice class SodaExceptionHandler { @@ -63,6 +64,7 @@ class SodaExceptionHandler { @ExceptionHandler(Exception::class) fun handleException(e: Exception) = run { + if (e is ResponseStatusException) throw e logger.error("API error", e) ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index cc42fbb..337f74e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -96,6 +96,7 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() + .antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll() .anyRequest().authenticated() .and() .build() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt index f9b4289..d4267c4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt @@ -53,7 +53,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac .innerJoin(useCan.room, liveRoom) .innerJoin(liveRoom.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( useCan.isRefund.isFalse .and(useCan.createdAt.goe(startDate)) @@ -119,7 +122,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac .innerJoin(order.audioContent, audioContent) .innerJoin(audioContent.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( order.createdAt.goe(startDate) .and(order.createdAt.loe(endDate)) @@ -196,7 +202,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac .innerJoin(order.audioContent, audioContent) .innerJoin(audioContent.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( audioContent.member.id.eq(memberId) .and(order.isActive.isTrue) @@ -318,7 +327,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac .innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(creatorCommunity.member, member) .leftJoin(creatorSettlementRatio) - .on(member.id.eq(creatorSettlementRatio.member.id)) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) .where( useCan.isRefund.isFalse .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingHistory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingHistory.kt index ee5615f..31a58cd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingHistory.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingHistory.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.marketing +import java.math.BigDecimal import java.time.LocalDateTime +import javax.persistence.Column import javax.persistence.Entity import javax.persistence.EnumType import javax.persistence.Enumerated @@ -19,7 +21,8 @@ data class AdTrackingHistory( val pidName: String, @Enumerated(value = EnumType.STRING) val type: AdTrackingHistoryType, - val price: Double = 0.toDouble(), + @Column(precision = 10, scale = 4, nullable = false) + val price: BigDecimal = 0.toBigDecimal(), val locale: String? = null, val memberId: Long, val createdAt: LocalDateTime = LocalDateTime.now(), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingService.kt index 99b6c9b..538b8db 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingService.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.springframework.stereotype.Service +import java.math.BigDecimal @Service class AdTrackingService( @@ -17,7 +18,7 @@ class AdTrackingService( pid: String, type: AdTrackingHistoryType, memberId: Long, - price: Double? = null, + price: BigDecimal? = null, locale: String? = null ) { coroutineScope.launch { @@ -30,7 +31,7 @@ class AdTrackingService( pid = pid, pidName = mediaPartner.pidName, type = type, - price = price ?: 0.toDouble(), + price = price ?: 0.toBigDecimal(), locale = locale, memberId = memberId ) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa37c6d..c268ead 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,7 @@ server: shutdown: graceful env: ${SERVER_ENV} + forward-headers-strategy: framework logging: level: @@ -13,6 +14,16 @@ weraser: apiUrl: ${WERASER_API_URL} apiKey: ${WERASER_API_KEY} +payverse: + host: ${PAYVERSE_HOST} + inboundIp: ${PAYVERSE_INBOUND_IP} + mid: ${PAYVERSE_MID} + clientKey: ${PAYVERSE_CLIENT_KEY} + secretKey: ${PAYVERSE_SECRET_KEY} + usdMid: ${PAYVERSE_USD_MID} + usdClientKey: ${PAYVERSE_USD_CLIENT_KEY} + usdSecretKey: ${PAYVERSE_USD_SECRET_KEY} + bootpay: applicationId: ${BOOTPAY_APPLICATION_ID} privateKey: ${BOOTPAY_PRIVATE_KEY}