Merge pull request 'test' (#277) from test into main

Reviewed-on: #277
This commit is contained in:
klaus 2025-03-05 09:44:59 +00:00
commit 01a88964df
21 changed files with 584 additions and 28 deletions

View File

@ -66,6 +66,7 @@ dependencies {
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0") implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0")
implementation("org.apache.poi:poi-ooxml:5.2.3") 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") developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2")

View File

@ -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()
)
)
}

View File

@ -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"
}
}

View File

@ -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
)
}
}

View File

@ -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<GetAdminAdMediaPartnerResponseItem>
)
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
)

View File

@ -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?
)

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.can.charge
data class ChargeCompleteResponse(
val price: Double,
val currencyCode: String,
val isFirstCharged: Boolean
)

View File

@ -3,17 +3,22 @@ package kr.co.vividnext.sodalive.can.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException 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 kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal 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.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
@RestController @RestController
@RequestMapping("/charge") @RequestMapping("/charge")
class ChargeController(private val service: ChargeService) { class ChargeController(
private val service: ChargeService,
private val trackingService: AdTrackingService
) {
@PostMapping @PostMapping
fun charge( fun charge(
@ -30,14 +35,30 @@ class ChargeController(private val service: ChargeService) {
@PostMapping("/verify") @PostMapping("/verify")
fun verify( fun verify(
@RequestBody verifyRequest: VerifyRequest, @RequestBody verifyRequest: VerifyRequest,
@AuthenticationPrincipal user: User @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = ApiResponse.ok(service.verify(user, verifyRequest)) ) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
val response = service.verify(memberId = member.id!!, verifyRequest)
trackingCharge(member, response)
ApiResponse.ok(Unit)
}
@PostMapping("/verify/hecto") @PostMapping("/verify/hecto")
fun verifyHecto( fun verifyHecto(
@RequestBody verifyRequest: VerifyRequest, @RequestBody verifyRequest: VerifyRequest,
@AuthenticationPrincipal user: User @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = ApiResponse.ok(service.verifyHecto(user, verifyRequest)) ) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
val response = service.verifyHecto(memberId = member.id!!, verifyRequest)
trackingCharge(member, response)
ApiResponse.ok(Unit)
}
@PostMapping("/apple") @PostMapping("/apple")
fun appleCharge( fun appleCharge(
@ -54,8 +75,16 @@ class ChargeController(private val service: ChargeService) {
@PostMapping("/apple/verify") @PostMapping("/apple/verify")
fun appleVerify( fun appleVerify(
@RequestBody verifyRequest: AppleVerifyRequest, @RequestBody verifyRequest: AppleVerifyRequest,
@AuthenticationPrincipal user: User @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = ApiResponse.ok(service.appleVerify(user, verifyRequest)) ) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
val response = service.appleVerify(memberId = member.id!!, verifyRequest)
trackingCharge(member, response)
ApiResponse.ok(Unit)
}
@PostMapping("/google") @PostMapping("/google")
fun googleCharge( fun googleCharge(
@ -78,17 +107,40 @@ class ChargeController(private val service: ChargeService) {
paymentGateway = request.paymentGateway paymentGateway = request.paymentGateway
) )
ApiResponse.ok( val response = service.processGoogleIap(
service.processGoogleIap( memberId = member.id!!,
memberId = member.id!!, chargeId = chargeId,
chargeId = chargeId, productId = request.productId,
productId = request.productId, purchaseToken = request.purchaseToken,
purchaseToken = request.purchaseToken, paymentGateway = request.paymentGateway
paymentGateway = request.paymentGateway
)
) )
trackingCharge(member, response)
ApiResponse.ok(Unit)
} else { } else {
throw SodaException("결제정보에 오류가 있습니다.") 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
)
}
}
} }

View File

@ -18,6 +18,7 @@ interface ChargeQueryRepository {
fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge? fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge? fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
fun getChargeCountAfterDate(memberId: Long, date: LocalDateTime): Int fun getChargeCountAfterDate(memberId: Long, date: LocalDateTime): Int
fun isFirstCharged(memberId: Long): Boolean
} }
class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ChargeQueryRepository { class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ChargeQueryRepository {
@ -76,6 +77,21 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha
.size .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? { private fun getPaymentGatewayCondition(container: String): BooleanExpression? {
val paymentGatewayCondition = when (container) { val paymentGatewayCondition = when (container) {
"aos" -> { "aos" -> {

View File

@ -23,9 +23,10 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Backoff
import org.springframework.retry.annotation.Retryable import org.springframework.retry.annotation.Retryable
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
@ -102,10 +103,10 @@ class ChargeService(
} }
@Transactional @Transactional
fun verify(user: User, verifyRequest: VerifyRequest) { fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
?: throw SodaException("결제정보에 오류가 있습니다.") ?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByEmail(user.username) val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException("로그인 정보를 확인해주세요.") ?: throw SodaException("로그인 정보를 확인해주세요.")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) { if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
@ -130,6 +131,12 @@ class ChargeService(
memberId = member.id!! 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 { } else {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
@ -142,10 +149,10 @@ class ChargeService(
} }
@Transactional @Transactional
fun verifyHecto(user: User, verifyRequest: VerifyRequest) { fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
?: throw SodaException("결제정보에 오류가 있습니다.") ?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByEmail(user.username) val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException("로그인 정보를 확인해주세요.") ?: throw SodaException("로그인 정보를 확인해주세요.")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) { if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
@ -174,6 +181,12 @@ class ChargeService(
memberId = member.id!! 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 { } else {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
@ -208,10 +221,10 @@ class ChargeService(
} }
@Transactional @Transactional
fun appleVerify(user: User, verifyRequest: AppleVerifyRequest) { fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId) val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId)
?: throw SodaException("결제정보에 오류가 있습니다.") ?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByEmail(user.username) val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException("로그인 정보를 확인해주세요.") ?: throw SodaException("로그인 정보를 확인해주세요.")
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) { if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
@ -228,6 +241,12 @@ class ChargeService(
memberId = member.id!! 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 { } else {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
@ -271,7 +290,7 @@ class ChargeService(
productId: String, productId: String,
purchaseToken: String, purchaseToken: String,
paymentGateway: PaymentGateway paymentGateway: PaymentGateway
) { ): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(id = chargeId) val charge = chargeRepository.findByIdOrNull(id = chargeId)
?: throw SodaException("결제정보에 오류가 있습니다.") ?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByIdOrNull(id = memberId) val member = memberRepository.findByIdOrNull(id = memberId)
@ -290,6 +309,12 @@ class ChargeService(
memberId = member.id!! 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 { } else {
throw SodaException("구매를 하지 못했습니다.\n고객센터로 문의해 주세요") throw SodaException("구매를 하지 못했습니다.\n고객센터로 문의해 주세요")
} }

View File

@ -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
}

View File

@ -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<AdMediaPartner, Long>, AdMediaPartnerQueryRepository
interface AdMediaPartnerQueryRepository {
fun findByPid(pid: String): AdMediaPartner?
fun getMediaPartnerList(offset: Long, limit: Long): List<GetAdminAdMediaPartnerResponseItem>
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<GetAdminAdMediaPartnerResponseItem> {
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<LocalDateTime>): 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"
)
}
}

View File

@ -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
}

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.marketing
import org.springframework.data.jpa.repository.JpaRepository
interface AdTrackingRepository : JpaRepository<AdTrackingHistory, AdTrackingHistoryId>

View File

@ -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()
}
}
}
}

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.member
data class MarketingInfoUpdateRequest(
val adid: String,
val pid: String
)

View File

@ -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.notification.MemberNotification
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree
import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag
import java.time.LocalDateTime
import javax.persistence.CascadeType import javax.persistence.CascadeType
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
@ -29,6 +30,12 @@ data class Member(
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var role: MemberRole = MemberRole.USER, var role: MemberRole = MemberRole.USER,
@Column(nullable = true)
var activePid: String? = null,
@Column(nullable = true)
var partnerExpirationDatetime: LocalDateTime? = null,
var isVisibleDonationRank: Boolean = true, var isVisibleDonationRank: Boolean = true,
var isActive: Boolean = true, var isActive: Boolean = true,

View File

@ -2,9 +2,12 @@ package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException 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.block.MemberBlockRequest
import kr.co.vividnext.sodalive.member.following.CreatorFollowRequest import kr.co.vividnext.sodalive.member.following.CreatorFollowRequest
import kr.co.vividnext.sodalive.member.login.LoginRequest 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 kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
@ -23,7 +26,10 @@ import org.springframework.web.multipart.MultipartFile
@RestController @RestController
@RequestMapping("/member") @RequestMapping("/member")
class MemberController(private val service: MemberService) { class MemberController(
private val service: MemberService,
private val trackingService: AdTrackingService
) {
@GetMapping("/check/email") @GetMapping("/check/email")
fun checkEmail(@RequestParam email: String) = service.duplicateCheckEmail(email) fun checkEmail(@RequestParam email: String) = service.duplicateCheckEmail(email)
@ -40,7 +46,19 @@ class MemberController(private val service: MemberService) {
fun signUp( fun signUp(
@RequestPart("profileImage", required = false) profileImage: MultipartFile? = null, @RequestPart("profileImage", required = false) profileImage: MultipartFile? = null,
@RequestPart("request") requestString: String @RequestPart("request") requestString: String
) = service.signUp(profileImage, requestString) ): ApiResponse<LoginResponse> {
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") @PostMapping("/login")
fun login(@RequestBody loginRequest: LoginRequest) = service.login(loginRequest) 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") @PutMapping("/adid/update")
fun updateAdid( fun updateAdid(
@RequestBody request: AdidUpdateRequest, @RequestBody request: AdidUpdateRequest,

View File

@ -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.MemberNotificationService
import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.member.signUp.SignUpRequest 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.signUp.SignUpValidator
import kr.co.vividnext.sodalive.member.stipulation.Stipulation import kr.co.vividnext.sodalive.member.stipulation.Stipulation
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree
@ -97,7 +98,7 @@ class MemberService(
fun signUp( fun signUp(
profileImage: MultipartFile?, profileImage: MultipartFile?,
requestString: String requestString: String
): ApiResponse<LoginResponse> { ): SignUpResponse {
val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID)
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
@ -117,7 +118,11 @@ class MemberService(
member.profileImage = uploadProfileImage(profileImage = profileImage, memberId = member.id!!) member.profileImage = uploadProfileImage(profileImage = profileImage, memberId = member.id!!)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) 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<LoginResponse> { fun login(request: LoginRequest): ApiResponse<LoginResponse> {
@ -289,6 +294,11 @@ class MemberService(
container = request.container container = request.container
) )
if (!request.marketingPid.isNullOrBlank()) {
member.activePid = request.marketingPid
member.partnerExpirationDatetime = LocalDateTime.now().plusYears(1)
}
return repository.save(member) return repository.save(member)
} }
@ -636,4 +646,19 @@ class MemberService(
private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock { private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock {
return tokenLocks.computeIfAbsent(memberId) { 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)
}
}
} }

View File

@ -7,6 +7,7 @@ data class SignUpRequest(
val password: String, val password: String,
val nickname: String, val nickname: String,
val gender: Gender, val gender: Gender,
val marketingPid: String? = null,
val isAgreeTermsOfService: Boolean, val isAgreeTermsOfService: Boolean,
val isAgreePrivacyPolicy: Boolean, val isAgreePrivacyPolicy: Boolean,
val container: String = "api" val container: String = "api"

View File

@ -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
)