diff --git a/build.gradle.kts b/build.gradle.kts index b260db1..46061c5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0") implementation("org.apache.poi:poi-ooxml:5.2.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerController.kt new file mode 100644 index 0000000..e83d063 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerController.kt @@ -0,0 +1,34 @@ +package kr.co.vividnext.sodalive.admin.marketing + +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.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@PreAuthorize("hasRole('ADMIN')") +@RequestMapping("/admin/marketing/media-partner") +class AdminAdMediaPartnerController(private val service: AdminAdMediaPartnerService) { + @PostMapping + fun createMediaPartner( + @RequestBody request: CreateAdMediaPartnerRequest + ) = ApiResponse.ok(service.createMediaPartner(request)) + + @PutMapping + fun updateMediaPartner( + @RequestBody request: UpdateAdMediaPartnerRequest + ) = ApiResponse.ok(service.updateMediaPartner(request)) + + @GetMapping + fun getMediaPartnerList(pageable: Pageable) = ApiResponse.ok( + service.getMediaPartnerList( + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerService.kt new file mode 100644 index 0000000..62d0759 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerService.kt @@ -0,0 +1,88 @@ +package kr.co.vividnext.sodalive.admin.marketing + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.marketing.AdMediaPartnerRepository +import kr.co.vividnext.sodalive.marketing.AdMediaPartnerType +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminAdMediaPartnerService(private val repository: AdMediaPartnerRepository) { + @Transactional + fun createMediaPartner(request: CreateAdMediaPartnerRequest) { + val mediaPartner = request.toEntity() + repository.save(mediaPartner) + } + + @Transactional + fun updateMediaPartner(request: UpdateAdMediaPartnerRequest) { + val entity = repository.findByIdOrNull(request.id) + ?: throw SodaException("잘못된 접근입니다") + + if (request.mediaGroup != null) { + entity.mediaGroup = request.mediaGroup + } + + if (request.pid != null) { + entity.pid = request.pid + } + + if (request.pidName != null) { + entity.pidName = request.pidName + } + + if (request.type != null) { + entity.type = request.type + } + + if (request.utmSource != null) { + entity.utmSource = request.utmSource + } + + if (request.utmMedium != null) { + entity.utmMedium = request.utmMedium + } + + if (request.isActive != null) { + entity.isActive = request.isActive + } + } + + fun getMediaPartnerList(offset: Long, limit: Long): GetAdminAdMediaPartnerResponse { + val totalCount = repository.getMediaPartnerListTotalCount() + val items = repository.getMediaPartnerList(offset, limit) + .map { + val deepLinkValue = when (it.type) { + AdMediaPartnerType.SERIES -> "series" + AdMediaPartnerType.CONTENT -> "content" + AdMediaPartnerType.LIVE -> "live" + AdMediaPartnerType.CHANNEL -> "channel" + AdMediaPartnerType.MAIN -> "main" + } + + val link = "$oneLinkHost?" + + "af_dp=voiceon://" + + "&deep_link_value=$deepLinkValue" + + "&deep_link_sub1=${it.pid}" + + "&deep_link_sub2=${it.utmSource}" + + "&deep_link_sub3=${it.utmMedium}" + + "&deep_link_sub4=${it.pidName}" + + "&utm_source=${it.utmSource}" + + "&utm_medium=${it.utmMedium}" + + "&utm_campaign=${it.pidName}" + + it.link = link + it + } + + return GetAdminAdMediaPartnerResponse( + totalCount = totalCount, + items = items + ) + } + + companion object { + private val oneLinkHost = "https://voiceon.onelink.me/RkTm" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/CreateAdMediaPartnerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/CreateAdMediaPartnerRequest.kt new file mode 100644 index 0000000..e3f9620 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/CreateAdMediaPartnerRequest.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.admin.marketing + +import kr.co.vividnext.sodalive.marketing.AdMediaPartner +import kr.co.vividnext.sodalive.marketing.AdMediaPartnerType + +data class CreateAdMediaPartnerRequest( + val mediaGroup: String, + val pid: String, + val pidName: String, + val type: AdMediaPartnerType, + val utmSource: String, + val utmMedium: String +) { + fun toEntity(): AdMediaPartner { + return AdMediaPartner( + mediaGroup = mediaGroup, + pid = pid, + pidName = pidName, + type = type, + utmSource = utmSource, + utmMedium = utmMedium + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/GetAdminAdMediaPartnerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/GetAdminAdMediaPartnerResponse.kt new file mode 100644 index 0000000..5b0bd18 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/GetAdminAdMediaPartnerResponse.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.admin.marketing + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.marketing.AdMediaPartnerType + +data class GetAdminAdMediaPartnerResponse( + val totalCount: Int, + val items: List +) + +data class GetAdminAdMediaPartnerResponseItem @QueryProjection constructor( + val id: Long, + val mediaGroup: String, + val pid: String, + val pidName: String, + val type: AdMediaPartnerType, + val utmSource: String, + val utmMedium: String, + val isActive: Boolean, + val createdAt: String, + var link: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/UpdateAdMediaPartnerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/UpdateAdMediaPartnerRequest.kt new file mode 100644 index 0000000..2bff5d7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/UpdateAdMediaPartnerRequest.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.admin.marketing + +import kr.co.vividnext.sodalive.marketing.AdMediaPartnerType + +data class UpdateAdMediaPartnerRequest( + val id: Long, + val mediaGroup: String?, + val pid: String?, + val pidName: String?, + val type: AdMediaPartnerType?, + val utmSource: String?, + val utmMedium: String?, + val isActive: Boolean? +) 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 new file mode 100644 index 0000000..066cd8f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.can.charge + +data class ChargeCompleteResponse( + val price: Double, + 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 cd6b8da..3748c96 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 @@ -3,17 +3,22 @@ package kr.co.vividnext.sodalive.can.charge import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.common.ApiResponse 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.security.core.annotation.AuthenticationPrincipal -import org.springframework.security.core.userdetails.User 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 java.time.LocalDateTime @RestController @RequestMapping("/charge") -class ChargeController(private val service: ChargeService) { +class ChargeController( + private val service: ChargeService, + private val trackingService: AdTrackingService +) { @PostMapping fun charge( @@ -30,14 +35,30 @@ class ChargeController(private val service: ChargeService) { @PostMapping("/verify") fun verify( @RequestBody verifyRequest: VerifyRequest, - @AuthenticationPrincipal user: User - ) = ApiResponse.ok(service.verify(user, verifyRequest)) + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + val response = service.verify(memberId = member.id!!, verifyRequest) + trackingCharge(member, response) + ApiResponse.ok(Unit) + } @PostMapping("/verify/hecto") fun verifyHecto( @RequestBody verifyRequest: VerifyRequest, - @AuthenticationPrincipal user: User - ) = ApiResponse.ok(service.verifyHecto(user, verifyRequest)) + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + val response = service.verifyHecto(memberId = member.id!!, verifyRequest) + trackingCharge(member, response) + ApiResponse.ok(Unit) + } @PostMapping("/apple") fun appleCharge( @@ -54,8 +75,16 @@ class ChargeController(private val service: ChargeService) { @PostMapping("/apple/verify") fun appleVerify( @RequestBody verifyRequest: AppleVerifyRequest, - @AuthenticationPrincipal user: User - ) = ApiResponse.ok(service.appleVerify(user, verifyRequest)) + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + val response = service.appleVerify(memberId = member.id!!, verifyRequest) + trackingCharge(member, response) + ApiResponse.ok(Unit) + } @PostMapping("/google") fun googleCharge( @@ -78,17 +107,40 @@ class ChargeController(private val service: ChargeService) { paymentGateway = request.paymentGateway ) - ApiResponse.ok( - service.processGoogleIap( - memberId = member.id!!, - chargeId = chargeId, - productId = request.productId, - purchaseToken = request.purchaseToken, - paymentGateway = request.paymentGateway - ) + val response = service.processGoogleIap( + memberId = member.id!!, + chargeId = chargeId, + productId = request.productId, + purchaseToken = request.purchaseToken, + paymentGateway = request.paymentGateway ) + + trackingCharge(member, response) + ApiResponse.ok(Unit) } else { throw SodaException("결제정보에 오류가 있습니다.") } } + + private fun trackingCharge( + member: Member, + response: ChargeCompleteResponse + ) { + if ( + !member.activePid.isNullOrBlank() && + member.partnerExpirationDatetime?.isAfter(LocalDateTime.now()) == true + ) { + trackingService.saveTrackingHistory( + pid = member.activePid!!, + type = if (response.isFirstCharged) { + AdTrackingHistoryType.FIRST_PAYMENT + } else { + AdTrackingHistoryType.REPEAT_PAYMENT + }, + memberId = member.id!!, + price = response.price, + locale = response.currencyCode + ) + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt index 1b65e1a..dbb2128 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt @@ -18,6 +18,7 @@ interface ChargeQueryRepository { fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge? fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge? fun getChargeCountAfterDate(memberId: Long, date: LocalDateTime): Int + fun isFirstCharged(memberId: Long): Boolean } class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ChargeQueryRepository { @@ -76,6 +77,21 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha .size } + override fun isFirstCharged(memberId: Long): Boolean { + return queryFactory + .select(charge.id) + .from(charge) + .innerJoin(charge.member, member) + .innerJoin(charge.payment, payment) + .where( + member.id.eq(memberId), + charge.status.eq(ChargeStatus.CHARGE), + payment.status.eq(PaymentStatus.COMPLETE) + ) + .fetch() + .size <= 1 + } + private fun getPaymentGatewayCondition(container: String): BooleanExpression? { val paymentGatewayCondition = when (container) { "aos" -> { 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 64529b0..4b8486e 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 @@ -23,9 +23,10 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpHeaders import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Retryable -import org.springframework.security.core.userdetails.User import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.math.RoundingMode @Service @Transactional(readOnly = true) @@ -102,10 +103,10 @@ class ChargeService( } @Transactional - fun verify(user: User, verifyRequest: VerifyRequest) { + fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) ?: throw SodaException("결제정보에 오류가 있습니다.") - val member = memberRepository.findByEmail(user.username) + val member = memberRepository.findByIdOrNull(memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") if (charge.payment!!.paymentGateway == PaymentGateway.PG) { @@ -130,6 +131,12 @@ class ChargeService( memberId = member.id!! ) ) + + return ChargeCompleteResponse( + price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", + isFirstCharged = chargeRepository.isFirstCharged(memberId) + ) } else { throw SodaException("결제정보에 오류가 있습니다.") } @@ -142,10 +149,10 @@ class ChargeService( } @Transactional - fun verifyHecto(user: User, verifyRequest: VerifyRequest) { + fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) ?: throw SodaException("결제정보에 오류가 있습니다.") - val member = memberRepository.findByEmail(user.username) + val member = memberRepository.findByIdOrNull(memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") if (charge.payment!!.paymentGateway == PaymentGateway.PG) { @@ -174,6 +181,12 @@ class ChargeService( memberId = member.id!! ) ) + + return ChargeCompleteResponse( + price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", + isFirstCharged = chargeRepository.isFirstCharged(memberId) + ) } else { throw SodaException("결제정보에 오류가 있습니다.") } @@ -208,10 +221,10 @@ class ChargeService( } @Transactional - fun appleVerify(user: User, verifyRequest: AppleVerifyRequest) { + fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId) ?: throw SodaException("결제정보에 오류가 있습니다.") - val member = memberRepository.findByEmail(user.username) + val member = memberRepository.findByIdOrNull(memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) { @@ -228,6 +241,12 @@ class ChargeService( memberId = member.id!! ) ) + + return ChargeCompleteResponse( + price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", + isFirstCharged = chargeRepository.isFirstCharged(memberId) + ) } else { throw SodaException("결제정보에 오류가 있습니다.") } @@ -271,7 +290,7 @@ class ChargeService( productId: String, purchaseToken: String, paymentGateway: PaymentGateway - ) { + ): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(id = chargeId) ?: throw SodaException("결제정보에 오류가 있습니다.") val member = memberRepository.findByIdOrNull(id = memberId) @@ -290,6 +309,12 @@ class ChargeService( memberId = member.id!! ) ) + + return ChargeCompleteResponse( + price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", + isFirstCharged = chargeRepository.isFirstCharged(memberId) + ) } else { throw SodaException("구매를 하지 못했습니다.\n고객센터로 문의해 주세요") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdMediaPartner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdMediaPartner.kt new file mode 100644 index 0000000..9dc5c6a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdMediaPartner.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.marketing + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated + +@Entity +data class AdMediaPartner( + var mediaGroup: String, + var pid: String, + var pidName: String, + @Enumerated(value = EnumType.STRING) + var type: AdMediaPartnerType, + var utmSource: String, + var utmMedium: String, + var isActive: Boolean = true +) : BaseEntity() + +enum class AdMediaPartnerType { + SERIES, CONTENT, LIVE, CHANNEL, MAIN +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdMediaPartnerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdMediaPartnerRepository.kt new file mode 100644 index 0000000..2886fbc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdMediaPartnerRepository.kt @@ -0,0 +1,75 @@ +package kr.co.vividnext.sodalive.marketing + +import com.querydsl.core.types.dsl.DateTimePath +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.core.types.dsl.StringTemplate +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.marketing.GetAdminAdMediaPartnerResponseItem +import kr.co.vividnext.sodalive.admin.marketing.QGetAdminAdMediaPartnerResponseItem +import kr.co.vividnext.sodalive.marketing.QAdMediaPartner.adMediaPartner +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +interface AdMediaPartnerRepository : JpaRepository, AdMediaPartnerQueryRepository + +interface AdMediaPartnerQueryRepository { + fun findByPid(pid: String): AdMediaPartner? + fun getMediaPartnerList(offset: Long, limit: Long): List + fun getMediaPartnerListTotalCount(): Int +} + +@Repository +class AdMediaPartnerQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdMediaPartnerQueryRepository { + override fun findByPid(pid: String): AdMediaPartner? { + return queryFactory + .selectFrom(adMediaPartner) + .where(adMediaPartner.pid.eq(pid), adMediaPartner.isActive.isTrue) + .fetchFirst() + } + + override fun getMediaPartnerList(offset: Long, limit: Long): List { + return queryFactory + .select( + QGetAdminAdMediaPartnerResponseItem( + adMediaPartner.id, + adMediaPartner.mediaGroup, + adMediaPartner.pid, + adMediaPartner.pidName, + adMediaPartner.type, + adMediaPartner.utmSource, + adMediaPartner.utmMedium, + adMediaPartner.isActive, + getFormattedDate(adMediaPartner.createdAt), + Expressions.constant("") + ) + ) + .from(adMediaPartner) + .orderBy(adMediaPartner.isActive.desc(), adMediaPartner.id.asc()) + .offset(offset) + .limit(limit) + .fetch() + } + + override fun getMediaPartnerListTotalCount(): Int { + return queryFactory + .select(adMediaPartner.id) + .from(adMediaPartner) + .fetch() + .size + } + + private fun getFormattedDate(dateTimePath: DateTimePath): StringTemplate { + return Expressions.stringTemplate( + "DATE_FORMAT({0}, {1})", + Expressions.dateTimeTemplate( + LocalDateTime::class.java, + "CONVERT_TZ({0},{1},{2})", + dateTimePath, + "UTC", + "Asia/Seoul" + ), + "%Y-%m-%d %H:%i:%s" + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingHistory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingHistory.kt new file mode 100644 index 0000000..5a1eb2b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingHistory.kt @@ -0,0 +1,46 @@ +package kr.co.vividnext.sodalive.marketing + +import java.io.Serializable +import java.time.LocalDateTime +import javax.persistence.Embeddable +import javax.persistence.EmbeddedId +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.PreUpdate + +@Entity +data class AdTrackingHistory( + @EmbeddedId + val id: AdTrackingHistoryId, + val mediaGroup: String, + val pidName: String, + val price: Double = 0.toDouble(), + val locale: String? = null, + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + @PreUpdate + fun preUpdate() { + updatedAt = LocalDateTime.now() + } +} + +@Embeddable +data class AdTrackingHistoryId( + val pid: String, + val memberId: Long, + @Enumerated(value = EnumType.STRING) + val type: AdTrackingHistoryType, + val createdAt: LocalDateTime = LocalDateTime.now() +) : Serializable + +enum class AdTrackingHistoryType { + // 회원가입 + SIGNUP, + + // 첫결제 + FIRST_PAYMENT, + + // 재결제 + REPEAT_PAYMENT +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingRepository.kt new file mode 100644 index 0000000..6d99bd4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.marketing + +import org.springframework.data.jpa.repository.JpaRepository + +interface AdTrackingRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingService.kt new file mode 100644 index 0000000..13379a4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingService.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.marketing + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.springframework.stereotype.Service + +@Service +class AdTrackingService( + private val repository: AdTrackingRepository, + private val mediaPartnerRepository: AdMediaPartnerRepository +) { + + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + fun saveTrackingHistory( + pid: String, + type: AdTrackingHistoryType, + memberId: Long, + price: Double? = null, + locale: String? = null + ) { + coroutineScope.launch { + try { + val mediaPartner = mediaPartnerRepository.findByPid(pid) + + if (mediaPartner != null) { + val id = AdTrackingHistoryId(pid = pid, memberId = memberId, type = type) + val trackingHistory = AdTrackingHistory( + id = id, + mediaGroup = mediaPartner.mediaGroup, + pidName = mediaPartner.pidName, + price = price ?: 0.toDouble(), + locale = locale + ) + repository.save(trackingHistory) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MarketingInfoUpdateRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MarketingInfoUpdateRequest.kt new file mode 100644 index 0000000..6ba3dd8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MarketingInfoUpdateRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.member + +data class MarketingInfoUpdateRequest( + val adid: String, + val pid: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt index fa268fc..c99c541 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.member.notification.MemberNotification import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag +import java.time.LocalDateTime import javax.persistence.CascadeType import javax.persistence.Column import javax.persistence.Entity @@ -29,6 +30,12 @@ data class Member( @Enumerated(value = EnumType.STRING) var role: MemberRole = MemberRole.USER, + @Column(nullable = true) + var activePid: String? = null, + + @Column(nullable = true) + var partnerExpirationDatetime: LocalDateTime? = null, + var isVisibleDonationRank: Boolean = true, var isActive: Boolean = true, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 024981c..54f1f8b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -2,9 +2,12 @@ package kr.co.vividnext.sodalive.member import kr.co.vividnext.sodalive.common.ApiResponse 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.block.MemberBlockRequest import kr.co.vividnext.sodalive.member.following.CreatorFollowRequest import kr.co.vividnext.sodalive.member.login.LoginRequest +import kr.co.vividnext.sodalive.member.login.LoginResponse import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest import org.springframework.data.domain.Pageable import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -23,7 +26,10 @@ import org.springframework.web.multipart.MultipartFile @RestController @RequestMapping("/member") -class MemberController(private val service: MemberService) { +class MemberController( + private val service: MemberService, + private val trackingService: AdTrackingService +) { @GetMapping("/check/email") fun checkEmail(@RequestParam email: String) = service.duplicateCheckEmail(email) @@ -40,7 +46,19 @@ class MemberController(private val service: MemberService) { fun signUp( @RequestPart("profileImage", required = false) profileImage: MultipartFile? = null, @RequestPart("request") requestString: String - ) = service.signUp(profileImage, requestString) + ): ApiResponse { + val response = service.signUp(profileImage, requestString) + + if (!response.marketingPid.isNullOrBlank()) { + trackingService.saveTrackingHistory( + pid = response.marketingPid, + type = AdTrackingHistoryType.SIGNUP, + memberId = response.memberId + ) + } + + return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) + } @PostMapping("/login") fun login(@RequestBody loginRequest: LoginRequest) = service.login(loginRequest) @@ -110,6 +128,22 @@ class MemberController(private val service: MemberService) { ) } + @PutMapping("/marketing-info/update") + fun updateMarketingInfo( + @RequestBody request: MarketingInfoUpdateRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.updateMarketingInfo( + memberId = member.id!!, + adid = request.adid, + pid = request.pid + ) + ) + } + @PutMapping("/adid/update") fun updateAdid( @RequestBody request: AdidUpdateRequest, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index b7fd155..e646fac 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -27,6 +27,7 @@ import kr.co.vividnext.sodalive.member.nickname.NicknameChangeLogRepository import kr.co.vividnext.sodalive.member.notification.MemberNotificationService import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.member.signUp.SignUpRequest +import kr.co.vividnext.sodalive.member.signUp.SignUpResponse import kr.co.vividnext.sodalive.member.signUp.SignUpValidator import kr.co.vividnext.sodalive.member.stipulation.Stipulation import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree @@ -97,7 +98,7 @@ class MemberService( fun signUp( profileImage: MultipartFile?, requestString: String - ): ApiResponse { + ): SignUpResponse { val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") @@ -117,7 +118,11 @@ class MemberService( member.profileImage = uploadProfileImage(profileImage = profileImage, memberId = member.id!!) agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) - return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = login(request.email, request.password)) + return SignUpResponse( + memberId = member.id!!, + marketingPid = request.marketingPid, + loginResponse = login(request.email, request.password) + ) } fun login(request: LoginRequest): ApiResponse { @@ -289,6 +294,11 @@ class MemberService( container = request.container ) + if (!request.marketingPid.isNullOrBlank()) { + member.activePid = request.marketingPid + member.partnerExpirationDatetime = LocalDateTime.now().plusYears(1) + } + return repository.save(member) } @@ -636,4 +646,19 @@ class MemberService( private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock { return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } } + + @Transactional + fun updateMarketingInfo(memberId: Long, adid: String, pid: String) { + val member = repository.findByIdOrNull(id = memberId) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + if (adid != member.adid) { + member.adid = adid + } + + if (pid != member.activePid && pid.isNotBlank()) { + member.activePid = pid + member.partnerExpirationDatetime = LocalDateTime.now().plusYears(1) + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt index 1d962ab..42c7350 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt @@ -7,6 +7,7 @@ data class SignUpRequest( val password: String, val nickname: String, val gender: Gender, + val marketingPid: String? = null, val isAgreeTermsOfService: Boolean, val isAgreePrivacyPolicy: Boolean, val container: String = "api" diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpResponse.kt new file mode 100644 index 0000000..eacae08 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.member.signUp + +import kr.co.vividnext.sodalive.member.login.LoginResponse + +data class SignUpResponse( + val memberId: Long, + val marketingPid: String?, + val loginResponse: LoginResponse +)