Merge pull request '유저 행동 데이터, 포인트 추가' (#309) from test into main
Reviewed-on: #309
This commit is contained in:
commit
3c087bc275
|
@ -1,5 +1,6 @@
|
|||
package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
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.StringTemplate
|
||||
|
@ -51,6 +52,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||
|
||||
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
|
||||
val orderFormattedDate = getFormattedDate(order.createdAt)
|
||||
val pointGroup = CaseBuilder()
|
||||
.`when`(order.point.loe(0)).then(0)
|
||||
.otherwise(1)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCalculateContentQueryData(
|
||||
|
@ -62,6 +67,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||
order.can,
|
||||
order.id.count(),
|
||||
order.can.sum(),
|
||||
order.point.sum(),
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
)
|
||||
|
@ -80,6 +86,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||
order.type,
|
||||
orderFormattedDate,
|
||||
order.can,
|
||||
pointGroup,
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
||||
|
@ -113,6 +120,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||
}
|
||||
|
||||
fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> {
|
||||
val pointGroup = CaseBuilder()
|
||||
.`when`(order.point.loe(0)).then(0)
|
||||
.otherwise(1)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCumulativeSalesByContentQueryData(
|
||||
|
@ -123,6 +134,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||
order.can,
|
||||
order.id.count(),
|
||||
order.can.sum(),
|
||||
order.point.sum(),
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
)
|
||||
|
@ -132,7 +144,14 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(order.isActive.isTrue)
|
||||
.groupBy(member.id, audioContent.id, order.type, order.can)
|
||||
.groupBy(
|
||||
member.id,
|
||||
audioContent.id,
|
||||
order.type,
|
||||
order.can,
|
||||
pointGroup,
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.orderBy(member.id.desc(), audioContent.id.desc())
|
||||
|
|
|
@ -22,11 +22,15 @@ data class GetCalculateContentQueryData @QueryProjection constructor(
|
|||
val numberOfPeople: Long,
|
||||
// 합계
|
||||
val totalCan: Int,
|
||||
// 포인트
|
||||
val totalPoint: Int,
|
||||
// 정산비율
|
||||
val settlementRatio: Int?
|
||||
) {
|
||||
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
|
||||
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
||||
val orderTypeStr = if (totalPoint > 0) {
|
||||
"포인트"
|
||||
} else if (orderType == OrderType.RENTAL) {
|
||||
"대여"
|
||||
} else {
|
||||
"소장"
|
||||
|
|
|
@ -21,11 +21,15 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
|
|||
val numberOfPeople: Long,
|
||||
// 합계
|
||||
val totalCan: Int,
|
||||
// 포인트
|
||||
val totalPoint: Int,
|
||||
// 정산비율
|
||||
val settlementRatio: Int?
|
||||
) {
|
||||
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
|
||||
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
||||
val orderTypeStr = if (totalPoint > 0) {
|
||||
"포인트"
|
||||
} else if (orderType == OrderType.RENTAL) {
|
||||
"대여"
|
||||
} else {
|
||||
"소장"
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package kr.co.vividnext.sodalive.admin.point
|
||||
|
||||
import kr.co.vividnext.sodalive.point.PointRewardPolicy
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class CreatePointRewardPolicyRequest(
|
||||
val title: String,
|
||||
val actionType: ActionType,
|
||||
val threshold: Int,
|
||||
val pointAmount: Int,
|
||||
val startDate: String,
|
||||
val endDate: String
|
||||
) {
|
||||
fun toEntity(): PointRewardPolicy {
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
|
||||
return PointRewardPolicy(
|
||||
title = title,
|
||||
actionType = actionType,
|
||||
threshold = threshold,
|
||||
pointAmount = pointAmount,
|
||||
startDate = LocalDateTime.parse(startDate, dateTimeFormatter)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime(),
|
||||
endDate = if (endDate.isNotBlank()) {
|
||||
LocalDateTime.parse(endDate, dateTimeFormatter).withSecond(59)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
isActive = true
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package kr.co.vividnext.sodalive.admin.point
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
|
||||
data class GetPointRewardPolicyListResponse(
|
||||
val totalCount: Int,
|
||||
val items: List<GetPointRewardPolicyListItem>
|
||||
)
|
||||
|
||||
data class GetPointRewardPolicyListItem @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val actionType: ActionType,
|
||||
val threshold: Int,
|
||||
val pointAmount: Int,
|
||||
val startDate: String,
|
||||
val endDate: String,
|
||||
val isActive: Boolean
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
package kr.co.vividnext.sodalive.admin.point
|
||||
|
||||
data class ModifyPointRewardPolicyRequest(
|
||||
val title: String?,
|
||||
val startDate: String?,
|
||||
val endDate: String?,
|
||||
val isActive: Boolean?
|
||||
)
|
|
@ -0,0 +1,36 @@
|
|||
package kr.co.vividnext.sodalive.admin.point
|
||||
|
||||
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.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/point-policies")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class PointPolicyController(private val service: PointPolicyService) {
|
||||
@GetMapping
|
||||
fun getAll(pageable: Pageable) = ApiResponse.ok(
|
||||
service.getAll(
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping
|
||||
fun create(
|
||||
@RequestBody request: CreatePointRewardPolicyRequest
|
||||
) = ApiResponse.ok(service.create(request))
|
||||
|
||||
@PutMapping("/{id}")
|
||||
fun update(
|
||||
@PathVariable id: Long,
|
||||
@RequestBody request: ModifyPointRewardPolicyRequest
|
||||
) = ApiResponse.ok(service.update(id, request))
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package kr.co.vividnext.sodalive.admin.point
|
||||
|
||||
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.point.PointRewardPolicy
|
||||
import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface PointPolicyRepository : JpaRepository<PointRewardPolicy, Long>, PointPolicyQueryRepository
|
||||
|
||||
interface PointPolicyQueryRepository {
|
||||
fun getTotalCount(): Int
|
||||
fun getAll(offset: Long, limit: Long): List<GetPointRewardPolicyListItem>
|
||||
}
|
||||
|
||||
class PointPolicyQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : PointPolicyQueryRepository {
|
||||
override fun getTotalCount(): Int {
|
||||
return queryFactory
|
||||
.select(pointRewardPolicy.id)
|
||||
.from(pointRewardPolicy)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
override fun getAll(offset: Long, limit: Long): List<GetPointRewardPolicyListItem> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetPointRewardPolicyListItem(
|
||||
pointRewardPolicy.id,
|
||||
pointRewardPolicy.title,
|
||||
pointRewardPolicy.actionType,
|
||||
pointRewardPolicy.threshold,
|
||||
pointRewardPolicy.pointAmount,
|
||||
getFormattedDate(pointRewardPolicy.startDate),
|
||||
getFormattedDate(pointRewardPolicy.endDate),
|
||||
pointRewardPolicy.isActive
|
||||
)
|
||||
)
|
||||
.from(pointRewardPolicy)
|
||||
.orderBy(pointRewardPolicy.isActive.desc(), pointRewardPolicy.startDate.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
||||
return Expressions.stringTemplate(
|
||||
"COALESCE(DATE_FORMAT(CONVERT_TZ({0}, 'UTC', 'Asia/Seoul'), '%Y-%m-%d %H:%i'), '')",
|
||||
dateTimePath
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package kr.co.vividnext.sodalive.admin.point
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class PointPolicyService(private val repository: PointPolicyRepository) {
|
||||
fun getAll(offset: Long, limit: Long): GetPointRewardPolicyListResponse {
|
||||
val totalCount = repository.getTotalCount()
|
||||
val items = repository.getAll(offset, limit)
|
||||
|
||||
return GetPointRewardPolicyListResponse(totalCount, items)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(request: CreatePointRewardPolicyRequest) {
|
||||
val pointPolicy = request.toEntity()
|
||||
repository.save(pointPolicy)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(id: Long, request: ModifyPointRewardPolicyRequest) {
|
||||
val pointPolicy = repository.findByIdOrNull(id)
|
||||
?: throw SodaException("잘못된 접근입니다.")
|
||||
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
|
||||
if (request.title != null) {
|
||||
pointPolicy.title = request.title
|
||||
}
|
||||
|
||||
if (request.startDate != null) {
|
||||
pointPolicy.startDate = LocalDateTime.parse(request.startDate, dateTimeFormatter)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
}
|
||||
|
||||
if (request.endDate != null) {
|
||||
pointPolicy.endDate = LocalDateTime.parse(request.endDate, dateTimeFormatter).withSecond(59)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
}
|
||||
|
||||
if (request.isActive != null) {
|
||||
pointPolicy.isActive = request.isActive
|
||||
}
|
||||
}
|
||||
}
|
|
@ -49,10 +49,12 @@ class AuditionApplicantQueryRepositoryImpl(
|
|||
return queryFactory
|
||||
.select(auditionApplicant.id)
|
||||
.from(auditionApplicant)
|
||||
.innerJoin(auditionApplicant.member, member)
|
||||
.innerJoin(auditionApplicant.role, auditionRole)
|
||||
.where(
|
||||
auditionRole.id.eq(auditionRoleId),
|
||||
auditionApplicant.isActive.isTrue
|
||||
auditionApplicant.isActive.isTrue,
|
||||
member.isActive.isTrue
|
||||
)
|
||||
.fetch()
|
||||
.size
|
||||
|
@ -87,7 +89,8 @@ class AuditionApplicantQueryRepositoryImpl(
|
|||
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
|
||||
.where(
|
||||
auditionRole.id.eq(auditionRoleId),
|
||||
auditionApplicant.isActive.isTrue
|
||||
auditionApplicant.isActive.isTrue,
|
||||
member.isActive.isTrue
|
||||
)
|
||||
.groupBy(auditionApplicant.id)
|
||||
.orderBy(orderBy)
|
||||
|
|
|
@ -42,6 +42,7 @@ data class AudioContent(
|
|||
val isGeneratePreview: Boolean = true,
|
||||
var isOnlyRental: Boolean = false,
|
||||
var isAdult: Boolean = false,
|
||||
var isPointAvailable: Boolean = false,
|
||||
var isCommentAvailable: Boolean = true,
|
||||
var isFullDetailVisible: Boolean = true
|
||||
) : BaseEntity() {
|
||||
|
|
|
@ -214,6 +214,7 @@ class AudioContentService(
|
|||
purchaseOption = purchaseOption,
|
||||
isGeneratePreview = request.isGeneratePreview,
|
||||
isOnlyRental = isOnlyRental,
|
||||
isPointAvailable = request.isPointAvailable,
|
||||
isCommentAvailable = request.isCommentAvailable,
|
||||
isFullDetailVisible = isFullDetailVisible
|
||||
)
|
||||
|
@ -707,7 +708,8 @@ class AudioContentService(
|
|||
),
|
||||
previousContent = previousContent,
|
||||
nextContent = nextContent,
|
||||
buyerList = buyerList
|
||||
buyerList = buyerList,
|
||||
isAvailableUsePoint = audioContent.isPointAvailable
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ data class CreateAudioContentRequest(
|
|||
val isAdult: Boolean = false,
|
||||
val isGeneratePreview: Boolean = false,
|
||||
val isOnlyRental: Boolean = false,
|
||||
val isPointAvailable: Boolean = false,
|
||||
val isCommentAvailable: Boolean = false,
|
||||
val isFullDetailVisible: Boolean = true,
|
||||
val previewStartTime: String? = null,
|
||||
|
|
|
@ -38,7 +38,8 @@ data class GetAudioContentDetailResponse(
|
|||
val creator: AudioContentCreator,
|
||||
val previousContent: OtherContentResponse?,
|
||||
val nextContent: OtherContentResponse?,
|
||||
val buyerList: List<ContentBuyer>
|
||||
val buyerList: List<ContentBuyer>,
|
||||
val isAvailableUsePoint: Boolean
|
||||
)
|
||||
|
||||
data class OtherContentResponse @QueryProjection constructor(
|
||||
|
|
|
@ -25,6 +25,7 @@ data class Order(
|
|||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
var can: Int = 0
|
||||
var point: Int = 0
|
||||
|
||||
val startDate: LocalDateTime = LocalDateTime.now()
|
||||
var endDate: LocalDateTime? = null
|
||||
|
|
|
@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
|||
import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository
|
||||
import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.point.PointUsageService
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
@ -19,6 +20,7 @@ import java.time.LocalDateTime
|
|||
@Transactional(readOnly = true)
|
||||
class OrderService(
|
||||
private val repository: OrderRepository,
|
||||
private val pointUsageService: PointUsageService,
|
||||
private val canPaymentService: CanPaymentService,
|
||||
private val audioContentRepository: AudioContentRepository,
|
||||
private val audioContentCommentQueryRepository: AudioContentCommentRepository,
|
||||
|
@ -41,13 +43,27 @@ class OrderService(
|
|||
orderContent(orderType, content, member)
|
||||
}
|
||||
|
||||
canPaymentService.spendCan(
|
||||
memberId = member.id!!,
|
||||
needCan = order.can,
|
||||
canUsage = CanUsage.ORDER_CONTENT,
|
||||
order = order,
|
||||
container = container
|
||||
)
|
||||
val usedPoint = if (order.type == OrderType.RENTAL && content.isPointAvailable) {
|
||||
pointUsageService.usePoint(member.id!!, order.can)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
order.point = usedPoint
|
||||
|
||||
val remainingCan = order.can - (usedPoint / 10)
|
||||
if (order.type == OrderType.RENTAL && content.isPointAvailable && usedPoint > 0) {
|
||||
order.can = remainingCan
|
||||
}
|
||||
|
||||
if (remainingCan > 0) {
|
||||
canPaymentService.spendCan(
|
||||
memberId = member.id!!,
|
||||
needCan = remainingCan,
|
||||
canUsage = CanUsage.ORDER_CONTENT,
|
||||
order = order,
|
||||
container = container
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun orderContent(orderType: OrderType, content: AudioContent, member: Member): Order {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package kr.co.vividnext.sodalive.creator.admin.calculate
|
||||
|
||||
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.StringTemplate
|
||||
|
@ -95,6 +96,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||
limit: Long
|
||||
): List<GetCalculateContentQueryData> {
|
||||
val orderFormattedDate = getFormattedDate(order.createdAt)
|
||||
val pointGroup = CaseBuilder()
|
||||
.`when`(order.point.loe(0)).then(0)
|
||||
.otherwise(1)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCalculateContentQueryData(
|
||||
|
@ -106,6 +111,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||
order.can,
|
||||
order.id.count(),
|
||||
order.can.sum(),
|
||||
order.point.sum(),
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
)
|
||||
|
@ -125,6 +131,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||
order.type,
|
||||
orderFormattedDate,
|
||||
order.can,
|
||||
pointGroup,
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
.offset(offset)
|
||||
|
@ -167,6 +174,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetCumulativeSalesByContentQueryData> {
|
||||
val pointGroup = CaseBuilder()
|
||||
.`when`(order.point.loe(0)).then(0)
|
||||
.otherwise(1)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCumulativeSalesByContentQueryData(
|
||||
|
@ -177,6 +188,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||
order.can,
|
||||
order.id.count(),
|
||||
order.can.sum(),
|
||||
order.point.sum(),
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
)
|
||||
|
@ -189,7 +201,14 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||
audioContent.member.id.eq(memberId)
|
||||
.and(order.isActive.isTrue)
|
||||
)
|
||||
.groupBy(member.id, audioContent.id, order.type, order.can)
|
||||
.groupBy(
|
||||
member.id,
|
||||
audioContent.id,
|
||||
order.type,
|
||||
order.can,
|
||||
pointGroup,
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.orderBy(member.id.desc(), audioContent.id.desc())
|
||||
|
|
|
@ -84,6 +84,7 @@ class CreatorAdminAudioContentQueryRepositoryImpl(
|
|||
audioContent.limited,
|
||||
audioContent.remaining,
|
||||
audioContent.isAdult,
|
||||
audioContent.isPointAvailable,
|
||||
audioContent.isCommentAvailable,
|
||||
audioContent.duration,
|
||||
audioContent.content,
|
||||
|
|
|
@ -131,6 +131,10 @@ class CreatorAdminContentService(
|
|||
audioContent.isAdult = request.isAdult
|
||||
}
|
||||
|
||||
if (request.isPointAvailable != null) {
|
||||
audioContent.isPointAvailable = request.isPointAvailable
|
||||
}
|
||||
|
||||
if (request.isCommentAvailable != null) {
|
||||
audioContent.isCommentAvailable = request.isCommentAvailable
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ data class GetCreatorAdminContentListItem @QueryProjection constructor(
|
|||
val totalContentCount: Int?,
|
||||
val remainingContentCount: Int?,
|
||||
val isAdult: Boolean,
|
||||
val isPointAvailable: Boolean,
|
||||
val isCommentAvailable: Boolean,
|
||||
val remainingTime: String,
|
||||
var contentUrl: String,
|
||||
|
|
|
@ -7,5 +7,6 @@ data class UpdateCreatorAdminContentRequest(
|
|||
val price: Int?,
|
||||
val isAdult: Boolean?,
|
||||
val isActive: Boolean?,
|
||||
val isPointAvailable: Boolean?,
|
||||
val isCommentAvailable: Boolean?
|
||||
)
|
||||
|
|
|
@ -4,6 +4,9 @@ import com.google.firebase.messaging.AndroidConfig
|
|||
import com.google.firebase.messaging.ApnsConfig
|
||||
import com.google.firebase.messaging.Aps
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.google.firebase.messaging.FirebaseMessagingException
|
||||
import com.google.firebase.messaging.Message
|
||||
import com.google.firebase.messaging.MessagingErrorCode
|
||||
import com.google.firebase.messaging.MulticastMessage
|
||||
import com.google.firebase.messaging.Notification
|
||||
import org.slf4j.LoggerFactory
|
||||
|
@ -26,8 +29,14 @@ class FcmService {
|
|||
creatorId: Long? = null,
|
||||
auditionId: Long? = null
|
||||
) {
|
||||
if (tokens.isNotEmpty()) {
|
||||
logger.info("os: $container")
|
||||
if (tokens.isEmpty()) return
|
||||
logger.info("os: $container")
|
||||
|
||||
var targets = tokens
|
||||
val maxAttempts = 3
|
||||
var attempt = 1
|
||||
|
||||
while (attempt <= maxAttempts && targets.isNotEmpty()) {
|
||||
val multicastMessage = MulticastMessage.builder()
|
||||
.addAllTokens(tokens)
|
||||
|
||||
|
@ -82,8 +91,80 @@ class FcmService {
|
|||
}
|
||||
|
||||
val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
|
||||
logger.info("보내기 성공: ${response.successCount}")
|
||||
logger.info("보내기 실패: ${response.failureCount}")
|
||||
val failedTokens = mutableListOf<String>()
|
||||
|
||||
response.responses.forEachIndexed { index, res ->
|
||||
if (!res.isSuccessful) {
|
||||
val exception = res.exception
|
||||
val token = targets[index]
|
||||
|
||||
if (exception?.messagingErrorCode == MessagingErrorCode.UNREGISTERED) {
|
||||
logger.error("[FCM] ❌ UNREGISTERED → $token")
|
||||
// 필요 시 DB에서 삭제
|
||||
} else {
|
||||
logger.error("[FCM] ❌ 실패: $token / ${exception?.messagingErrorCode}")
|
||||
failedTokens.add(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failedTokens.isEmpty()) {
|
||||
logger.info("[FCM] ✅ 전체 전송 성공")
|
||||
return
|
||||
}
|
||||
|
||||
targets = failedTokens
|
||||
attempt++
|
||||
}
|
||||
|
||||
if (targets.isNotEmpty()) {
|
||||
logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPointGranted(token: String, point: Int) {
|
||||
val data = mapOf(
|
||||
"type" to "POINT_GRANTED",
|
||||
"point" to point.toString(),
|
||||
"message" to "${point}포인트가 지급되었습니다!"
|
||||
)
|
||||
|
||||
var attempts = 0
|
||||
val maxAttempts = 3
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
val message = Message.builder()
|
||||
.setToken(token)
|
||||
.putAllData(data)
|
||||
.build()
|
||||
|
||||
val response = FirebaseMessaging.getInstance().send(message)
|
||||
logger.info("[FCM] ✅ 성공 (attempt ${attempts + 1}): messageId=$response")
|
||||
return // 성공 시 즉시 종료
|
||||
} catch (e: FirebaseMessagingException) {
|
||||
attempts++
|
||||
|
||||
// "registration-token-not-registered" 예외 코드 확인
|
||||
if (e.messagingErrorCode == MessagingErrorCode.UNREGISTERED) {
|
||||
logger.error("[FCM] ❌ 실패: 토큰이 등록되지 않음 (등록 해제됨) → 재시도 안함")
|
||||
return
|
||||
}
|
||||
|
||||
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.errorCode} - ${e.message}")
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
logger.error("[FCM] ❌ 최종 실패: 전송 불가")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Firebase 이외의 예외도 잡기
|
||||
attempts++
|
||||
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.message}")
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
logger.error("[FCM] ❌ 최종 실패: 알 수 없는 오류")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingReq
|
|||
import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2
|
||||
import kr.co.vividnext.sodalive.member.social.google.GoogleAuthService
|
||||
import kr.co.vividnext.sodalive.member.social.kakao.KakaoAuthService
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import kr.co.vividnext.sodalive.useraction.UserActionService
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.core.userdetails.User
|
||||
|
@ -34,7 +36,8 @@ class MemberController(
|
|||
private val service: MemberService,
|
||||
private val kakaoAuthService: KakaoAuthService,
|
||||
private val googleAuthService: GoogleAuthService,
|
||||
private val trackingService: AdTrackingService
|
||||
private val trackingService: AdTrackingService,
|
||||
private val userActionService: UserActionService
|
||||
) {
|
||||
@GetMapping("/check/email")
|
||||
fun checkEmail(@RequestParam email: String) = service.duplicateCheckEmail(email)
|
||||
|
@ -60,6 +63,12 @@ class MemberController(
|
|||
)
|
||||
}
|
||||
|
||||
userActionService.recordAction(
|
||||
memberId = response.memberId,
|
||||
actionType = ActionType.SIGN_UP,
|
||||
pushToken = request.pushToken
|
||||
)
|
||||
|
||||
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
|
||||
}
|
||||
|
||||
|
@ -332,7 +341,7 @@ class MemberController(
|
|||
}
|
||||
|
||||
val token = authHeader.substring(7)
|
||||
val response = googleAuthService.authenticate(token, request.container, request.marketingPid)
|
||||
val response = googleAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken)
|
||||
|
||||
if (!response.marketingPid.isNullOrBlank()) {
|
||||
trackingService.saveTrackingHistory(
|
||||
|
@ -342,6 +351,12 @@ class MemberController(
|
|||
)
|
||||
}
|
||||
|
||||
userActionService.recordAction(
|
||||
memberId = response.memberId,
|
||||
actionType = ActionType.SIGN_UP,
|
||||
pushToken = request.pushToken
|
||||
)
|
||||
|
||||
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
|
||||
}
|
||||
|
||||
|
@ -355,7 +370,7 @@ class MemberController(
|
|||
}
|
||||
|
||||
val token = authHeader.substring(7)
|
||||
val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid)
|
||||
val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken)
|
||||
|
||||
if (!response.marketingPid.isNullOrBlank()) {
|
||||
trackingService.saveTrackingHistory(
|
||||
|
@ -365,6 +380,12 @@ class MemberController(
|
|||
)
|
||||
}
|
||||
|
||||
userActionService.recordAction(
|
||||
memberId = response.memberId,
|
||||
actionType = ActionType.SIGN_UP,
|
||||
pushToken = request.pushToken
|
||||
)
|
||||
|
||||
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository
|
|||
import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag
|
||||
import kr.co.vividnext.sodalive.member.tag.MemberTagRepository
|
||||
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||
import kr.co.vividnext.sodalive.point.MemberPointRepository
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import kr.co.vividnext.sodalive.utils.generatePassword
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
|
@ -77,6 +78,7 @@ class MemberService(
|
|||
private val memberTagRepository: MemberTagRepository,
|
||||
private val liveReservationRepository: LiveReservationRepository,
|
||||
private val chargeRepository: ChargeRepository,
|
||||
private val memberPointRepository: MemberPointRepository,
|
||||
|
||||
private val orderService: OrderService,
|
||||
private val emailService: SendEmailService,
|
||||
|
@ -125,6 +127,7 @@ class MemberService(
|
|||
gender = Gender.NONE,
|
||||
container = request.container
|
||||
)
|
||||
member.pushToken = request.pushToken
|
||||
|
||||
if (!request.marketingPid.isNullOrBlank()) {
|
||||
member.activePid = request.marketingPid
|
||||
|
@ -261,6 +264,11 @@ class MemberService(
|
|||
limit = 4
|
||||
)
|
||||
|
||||
val totalPoint = memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
|
||||
memberId = member.id!!,
|
||||
expiresAt = LocalDateTime.now()
|
||||
).sumOf { it.point }
|
||||
|
||||
return MyPageResponse(
|
||||
nickname = member.nickname,
|
||||
profileUrl = if (member.profileImage != null) {
|
||||
|
@ -270,6 +278,7 @@ class MemberService(
|
|||
},
|
||||
chargeCan = member.getChargeCan(container = container),
|
||||
rewardCan = member.getRewardCan(container = container),
|
||||
point = totalPoint,
|
||||
youtubeUrl = member.youtubeUrl,
|
||||
instagramUrl = member.instagramUrl,
|
||||
websiteUrl = member.websiteUrl,
|
||||
|
@ -780,7 +789,12 @@ class MemberService(
|
|||
}
|
||||
|
||||
@Transactional
|
||||
fun findOrRegister(googleUserInfo: GoogleUserInfo, container: String, marketingPid: String?): Member {
|
||||
fun findOrRegister(
|
||||
googleUserInfo: GoogleUserInfo,
|
||||
container: String,
|
||||
marketingPid: String?,
|
||||
pushToken: String?
|
||||
): Member {
|
||||
val findMember = repository.findByGoogleId(googleUserInfo.sub)
|
||||
if (findMember != null) {
|
||||
if (findMember.isActive) {
|
||||
|
@ -810,6 +824,7 @@ class MemberService(
|
|||
provider = MemberProvider.GOOGLE,
|
||||
container = container
|
||||
)
|
||||
member.pushToken = pushToken
|
||||
|
||||
if (!marketingPid.isNullOrBlank()) {
|
||||
member.activePid = marketingPid
|
||||
|
@ -823,7 +838,12 @@ class MemberService(
|
|||
}
|
||||
|
||||
@Transactional
|
||||
fun findOrRegister(kakaoUserInfo: KakaoUserInfo, container: String, marketingPid: String?): Member {
|
||||
fun findOrRegister(
|
||||
kakaoUserInfo: KakaoUserInfo,
|
||||
container: String,
|
||||
marketingPid: String?,
|
||||
pushToken: String?
|
||||
): Member {
|
||||
val findMember = repository.findByKakaoId(kakaoUserInfo.id)
|
||||
if (findMember != null) {
|
||||
if (findMember.isActive) {
|
||||
|
@ -853,6 +873,7 @@ class MemberService(
|
|||
provider = MemberProvider.KAKAO,
|
||||
container = container
|
||||
)
|
||||
member.pushToken = pushToken
|
||||
|
||||
if (!marketingPid.isNullOrBlank()) {
|
||||
member.activePid = marketingPid
|
||||
|
|
|
@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.member.auth
|
|||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import kr.co.vividnext.sodalive.useraction.UserActionService
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
|
@ -11,7 +13,10 @@ import org.springframework.web.bind.annotation.RestController
|
|||
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
class AuthController(private val service: AuthService) {
|
||||
class AuthController(
|
||||
private val service: AuthService,
|
||||
private val userActionService: UserActionService
|
||||
) {
|
||||
@PostMapping
|
||||
fun authVerify(
|
||||
@RequestBody request: AuthVerifyRequest,
|
||||
|
@ -26,6 +31,12 @@ class AuthController(private val service: AuthService) {
|
|||
throw SodaException("운영정책을 위반하여 이용을 제한합니다.")
|
||||
}
|
||||
|
||||
userActionService.recordAction(
|
||||
memberId = member.id!!,
|
||||
actionType = ActionType.USER_AUTHENTICATION,
|
||||
pushToken = member.pushToken
|
||||
)
|
||||
|
||||
ApiResponse.ok(service.authenticate(authenticateData, member.id!!))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,4 +7,8 @@ data class LoginRequest(
|
|||
val isCreator: Boolean = false
|
||||
)
|
||||
|
||||
data class SocialLoginRequest(val container: String, val marketingPid: String? = null)
|
||||
data class SocialLoginRequest(
|
||||
val container: String,
|
||||
val pushToken: String? = null,
|
||||
val marketingPid: String? = null
|
||||
)
|
||||
|
|
|
@ -7,6 +7,7 @@ data class MyPageResponse(
|
|||
val profileUrl: String,
|
||||
val chargeCan: Int,
|
||||
val rewardCan: Int,
|
||||
val point: Int,
|
||||
val youtubeUrl: String?,
|
||||
val instagramUrl: String?,
|
||||
val websiteUrl: String? = null,
|
||||
|
|
|
@ -16,6 +16,7 @@ data class SignUpRequest(
|
|||
data class SignUpRequestV2(
|
||||
val email: String,
|
||||
val password: String,
|
||||
val pushToken: String? = null,
|
||||
val marketingPid: String? = null,
|
||||
val isAgreeTermsOfService: Boolean,
|
||||
val isAgreePrivacyPolicy: Boolean,
|
||||
|
|
|
@ -19,10 +19,15 @@ class GoogleAuthService(
|
|||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
fun authenticate(idToken: String, container: String, marketingPid: String?): SocialLoginResponse {
|
||||
fun authenticate(
|
||||
idToken: String,
|
||||
container: String,
|
||||
marketingPid: String?,
|
||||
pushToken: String?
|
||||
): SocialLoginResponse {
|
||||
val googleUserInfo = googleService.getUserInfo(idToken)
|
||||
?: throw SodaException("구글 로그인을 하지 못했습니다. 다시 시도해 주세요")
|
||||
val member = memberService.findOrRegister(googleUserInfo, container, marketingPid)
|
||||
val member = memberService.findOrRegister(googleUserInfo, container, marketingPid, pushToken)
|
||||
val principal = MemberAdapter(member)
|
||||
val authToken = GoogleAuthenticationToken(idToken, principal.authorities)
|
||||
authToken.setPrincipal(principal)
|
||||
|
|
|
@ -19,10 +19,15 @@ class KakaoAuthService(
|
|||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
fun authenticate(accessToken: String, container: String, marketingPid: String?): SocialLoginResponse {
|
||||
fun authenticate(
|
||||
accessToken: String,
|
||||
container: String,
|
||||
marketingPid: String?,
|
||||
pushToken: String?
|
||||
): SocialLoginResponse {
|
||||
val kakaoUserInfo = kakaoService.getUserInfo(accessToken)
|
||||
?: throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요")
|
||||
val member = memberService.findOrRegister(kakaoUserInfo, container, marketingPid)
|
||||
val member = memberService.findOrRegister(kakaoUserInfo, container, marketingPid, pushToken)
|
||||
val principal = MemberAdapter(member)
|
||||
val authToken = KakaoAuthenticationToken(accessToken, principal.authorities)
|
||||
authToken.setPrincipal(principal)
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package kr.co.vividnext.sodalive.point
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
|
||||
@Entity
|
||||
data class MemberPoint(
|
||||
val memberId: Long,
|
||||
var point: Int,
|
||||
@Enumerated(EnumType.STRING)
|
||||
val actionType: ActionType,
|
||||
val expiresAt: LocalDateTime
|
||||
) : BaseEntity()
|
|
@ -0,0 +1,29 @@
|
|||
package kr.co.vividnext.sodalive.point
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.point.QMemberPoint.memberPoint
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface MemberPointRepository : JpaRepository<MemberPoint, Long>, MemberPointQueryRepository
|
||||
|
||||
interface MemberPointQueryRepository {
|
||||
fun findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(memberId: Long, expiresAt: LocalDateTime): List<MemberPoint>
|
||||
}
|
||||
|
||||
class MemberPointQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : MemberPointQueryRepository {
|
||||
override fun findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
|
||||
memberId: Long,
|
||||
expiresAt: LocalDateTime
|
||||
): List<MemberPoint> {
|
||||
return queryFactory
|
||||
.selectFrom(memberPoint)
|
||||
.where(
|
||||
memberPoint.memberId.eq(memberId),
|
||||
memberPoint.expiresAt.goe(expiresAt)
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package kr.co.vividnext.sodalive.point
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
|
||||
@Entity
|
||||
data class PointGrantLog(
|
||||
val memberId: Long,
|
||||
val point: Int,
|
||||
@Enumerated(EnumType.STRING)
|
||||
val actionType: ActionType,
|
||||
val policyId: Long?
|
||||
) : BaseEntity()
|
|
@ -0,0 +1,27 @@
|
|||
package kr.co.vividnext.sodalive.point
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.point.QPointGrantLog.pointGrantLog
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface PointGrantLogRepository : JpaRepository<PointGrantLog, Long>, PointGrantLogQueryRepository
|
||||
|
||||
interface PointGrantLogQueryRepository {
|
||||
fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean
|
||||
}
|
||||
|
||||
class PointGrantLogQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : PointGrantLogQueryRepository {
|
||||
override fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean {
|
||||
return queryFactory
|
||||
.select(pointGrantLog.id)
|
||||
.from(pointGrantLog)
|
||||
.where(
|
||||
pointGrantLog.memberId.eq(memberId),
|
||||
pointGrantLog.policyId.eq(policyId)
|
||||
)
|
||||
.fetch()
|
||||
.isNotEmpty()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package kr.co.vividnext.sodalive.point
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
|
||||
@Entity
|
||||
data class PointRewardPolicy(
|
||||
var title: String,
|
||||
@Enumerated(EnumType.STRING)
|
||||
val actionType: ActionType,
|
||||
val threshold: Int,
|
||||
val pointAmount: Int,
|
||||
var startDate: LocalDateTime,
|
||||
var endDate: LocalDateTime? = null,
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity()
|
|
@ -0,0 +1,36 @@
|
|||
package kr.co.vividnext.sodalive.point
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface PointRewardPolicyRepository : JpaRepository<PointRewardPolicy, Long>, PointRewardPolicyQueryRepository
|
||||
|
||||
interface PointRewardPolicyQueryRepository {
|
||||
fun findByActionTypeAndIsActiveTrue(actionType: ActionType, nowDateTime: LocalDateTime): PointRewardPolicy?
|
||||
}
|
||||
|
||||
class PointRewardPolicyQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : PointRewardPolicyQueryRepository {
|
||||
override fun findByActionTypeAndIsActiveTrue(
|
||||
actionType: ActionType,
|
||||
nowDateTime: LocalDateTime
|
||||
): PointRewardPolicy? {
|
||||
return queryFactory
|
||||
.selectFrom(pointRewardPolicy)
|
||||
.where(
|
||||
pointRewardPolicy.isActive.isTrue
|
||||
.and(pointRewardPolicy.actionType.eq(actionType))
|
||||
.and(pointRewardPolicy.startDate.loe(nowDateTime))
|
||||
.and(
|
||||
pointRewardPolicy.endDate.goe(nowDateTime)
|
||||
.or(pointRewardPolicy.endDate.isNull)
|
||||
)
|
||||
)
|
||||
.orderBy(pointRewardPolicy.endDate.asc())
|
||||
.fetchFirst()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package kr.co.vividnext.sodalive.point
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class PointUsageService(
|
||||
private val memberPointRepository: MemberPointRepository,
|
||||
private val usePointRepository: UsePointRepository
|
||||
) {
|
||||
fun usePoint(memberId: Long, contentPrice: Int): Int {
|
||||
val now = LocalDateTime.now()
|
||||
val maxUsablePoint = contentPrice * 10
|
||||
|
||||
val points = memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
|
||||
memberId = memberId,
|
||||
expiresAt = now
|
||||
)
|
||||
|
||||
val totalAvailable = points.sumOf { it.point }
|
||||
val usablePoint = minOf(totalAvailable, maxUsablePoint).floorToNearest10()
|
||||
|
||||
var remaining = usablePoint
|
||||
var used = 0
|
||||
|
||||
for (p in points) {
|
||||
if (remaining <= 0) break
|
||||
val usable = minOf(p.point, remaining)
|
||||
p.point -= usable
|
||||
remaining -= usable
|
||||
used += usable
|
||||
}
|
||||
|
||||
if (used > 0) {
|
||||
memberPointRepository.saveAll(points)
|
||||
usePointRepository.save(UsePoint(memberId = memberId, amount = used))
|
||||
}
|
||||
|
||||
return used
|
||||
}
|
||||
|
||||
private fun Int.floorToNearest10(): Int = (this / 10) * 10
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package kr.co.vividnext.sodalive.point
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
|
||||
@Entity
|
||||
data class UsePoint(
|
||||
val memberId: Long,
|
||||
val amount: Int
|
||||
) : BaseEntity()
|
|
@ -0,0 +1,5 @@
|
|||
package kr.co.vividnext.sodalive.point
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface UsePointRepository : JpaRepository<UsePoint, Long>
|
|
@ -0,0 +1,6 @@
|
|||
package kr.co.vividnext.sodalive.useraction
|
||||
|
||||
enum class ActionType(val displayName: String) {
|
||||
SIGN_UP("회원가입"),
|
||||
USER_AUTHENTICATION("본인인증")
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package kr.co.vividnext.sodalive.useraction
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
|
||||
@Entity
|
||||
data class UserActionLog(
|
||||
val memberId: Long,
|
||||
@Enumerated(EnumType.STRING)
|
||||
val actionType: ActionType
|
||||
) : BaseEntity()
|
|
@ -0,0 +1,39 @@
|
|||
package kr.co.vividnext.sodalive.useraction
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.useraction.QUserActionLog.userActionLog
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface UserActionLogRepository : JpaRepository<UserActionLog, Long>, UserActionLogQueryRepository
|
||||
|
||||
interface UserActionLogQueryRepository {
|
||||
fun countByMemberIdAndActionTypeAndCreatedAtBetween(
|
||||
memberId: Long,
|
||||
actionType: ActionType,
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime
|
||||
): Int
|
||||
}
|
||||
|
||||
class UserActionLogQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : UserActionLogQueryRepository {
|
||||
override fun countByMemberIdAndActionTypeAndCreatedAtBetween(
|
||||
memberId: Long,
|
||||
actionType: ActionType,
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime
|
||||
): Int {
|
||||
return queryFactory
|
||||
.select(userActionLog.id)
|
||||
.from(userActionLog)
|
||||
.where(
|
||||
userActionLog.memberId.eq(memberId)
|
||||
.and(userActionLog.actionType.eq(actionType))
|
||||
.and(userActionLog.createdAt.between(startDate, endDate))
|
||||
)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
package kr.co.vividnext.sodalive.useraction
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kr.co.vividnext.sodalive.fcm.FcmService
|
||||
import kr.co.vividnext.sodalive.point.MemberPoint
|
||||
import kr.co.vividnext.sodalive.point.MemberPointRepository
|
||||
import kr.co.vividnext.sodalive.point.PointGrantLog
|
||||
import kr.co.vividnext.sodalive.point.PointGrantLogRepository
|
||||
import kr.co.vividnext.sodalive.point.PointRewardPolicyRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class UserActionService(
|
||||
private val repository: UserActionLogRepository,
|
||||
private val policyRepository: PointRewardPolicyRepository,
|
||||
private val grantLogRepository: PointGrantLogRepository,
|
||||
private val memberPointRepository: MemberPointRepository,
|
||||
|
||||
private val fcmService: FcmService
|
||||
) {
|
||||
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
fun recordAction(memberId: Long, actionType: ActionType, pushToken: String?) {
|
||||
coroutineScope.launch {
|
||||
val now = LocalDateTime.now()
|
||||
repository.save(UserActionLog(memberId, actionType))
|
||||
|
||||
val policy = policyRepository.findByActionTypeAndIsActiveTrue(actionType, now)
|
||||
if (policy != null) {
|
||||
val actionCount = repository.countByMemberIdAndActionTypeAndCreatedAtBetween(
|
||||
memberId = memberId,
|
||||
actionType = actionType,
|
||||
startDate = policy.startDate,
|
||||
endDate = policy.endDate ?: now
|
||||
)
|
||||
if (actionCount < policy.threshold) return@launch
|
||||
|
||||
val alreadyGranted = grantLogRepository.existsByMemberIdAndPolicyId(memberId, policy.id!!)
|
||||
if (alreadyGranted) return@launch
|
||||
|
||||
memberPointRepository.save(
|
||||
MemberPoint(
|
||||
memberId = memberId,
|
||||
point = policy.pointAmount,
|
||||
actionType = actionType,
|
||||
expiresAt = now.plusDays(3)
|
||||
)
|
||||
)
|
||||
|
||||
grantLogRepository.save(
|
||||
PointGrantLog(
|
||||
memberId = memberId,
|
||||
point = policy.pointAmount,
|
||||
actionType = actionType,
|
||||
policyId = policy.id!!
|
||||
)
|
||||
)
|
||||
|
||||
if (pushToken != null) {
|
||||
fcmService.sendPointGranted(pushToken, policy.pointAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue