캔 쿠폰 시스템 #107

Merged
klaus merged 12 commits from test into main 2024-01-03 11:28:48 +00:00
19 changed files with 572 additions and 1 deletions

View File

@ -44,6 +44,7 @@ dependencies {
kapt("org.springframework.boot:spring-boot-configuration-processor")
// aws
implementation("com.amazonaws:aws-java-sdk-sqs:1.12.380")
implementation("com.amazonaws:aws-java-sdk-ses:1.12.380")
implementation("com.amazonaws:aws-java-sdk-s3:1.12.380")
implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380")
@ -58,6 +59,8 @@ dependencies {
// firebase admin sdk
implementation("com.google.firebase:firebase-admin:9.2.0")
implementation("org.apache.poi:poi-ooxml:5.2.3")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")

View File

@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.aws.sqs
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
enum class SqsEventType {
GENERATE_COUPON
}
class SqsEvent(
val type: SqsEventType,
val message: String
)
@Component
class SqsEventListener(private val sqsService: SqsService) {
@Async
@EventListener
fun sendMessage(sqsEvent: SqsEvent) {
when (sqsEvent.type) {
SqsEventType.GENERATE_COUPON -> sqsService.sendGenerateCouponMessage(sqsEvent.message)
}
}
}

View File

@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.aws.sqs
import com.amazonaws.services.sqs.AmazonSQS
import com.amazonaws.services.sqs.model.SendMessageRequest
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
@Service
class SqsService(
private val amazonSQS: AmazonSQS,
@Value("\${cloud.aws.sqs.generate-coupon-url}")
private val generateCouponQueueUrl: String
) {
fun sendGenerateCouponMessage(message: String) {
val request = SendMessageRequest()
.withQueueUrl(generateCouponQueueUrl)
.withMessageBody(message)
amazonSQS.sendMessage(request)
}
}

View File

@ -111,6 +111,10 @@ class CanService(private val repository: CanRepository) {
"제휴보상"
}
ChargeStatus.COUPON -> {
it.payment!!.method ?: "쿠폰충전"
}
else -> {
"환불"
}

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.bootpay.Bootpay
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent
import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
@ -29,6 +30,8 @@ class ChargeService(
private val chargeRepository: ChargeRepository,
private val canRepository: CanRepository,
private val memberRepository: MemberRepository,
private val couponNumberRepository: CanCouponNumberRepository,
private val objectMapper: ObjectMapper,
private val okHttpClient: OkHttpClient,
private val applicationEventPublisher: ApplicationEventPublisher,
@ -43,6 +46,33 @@ class ChargeService(
private val appleInAppVerifyUrl: String
) {
@Transactional
fun chargeByCoupon(couponNumber: String, member: Member) {
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
if (canCouponNumber.member != null) {
throw SodaException("이미 사용한 쿠폰번호 입니다.")
}
canCouponNumber.member = member
val coupon = canCouponNumber.canCoupon!!
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
couponCharge.title = "${coupon.can}"
couponCharge.member = member
val payment = Payment(
status = PaymentStatus.COMPLETE,
paymentGateway = PaymentGateway.PG
)
payment.method = coupon.couponName
couponCharge.payment = payment
chargeRepository.save(couponCharge)
member.charge(0, coupon.can, "pg")
}
@Transactional
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
val can = canRepository.findByIdOrNull(request.canId)

View File

@ -1,7 +1,7 @@
package kr.co.vividnext.sodalive.can.charge
enum class ChargeStatus {
CHARGE, REFUND_CHARGE, EVENT, CANCEL,
CHARGE, REFUND_CHARGE, EVENT, COUPON, CANCEL,
// 관리자 지급
ADMIN,

View File

@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.can.coupon
import kr.co.vividnext.sodalive.common.BaseEntity
import java.time.LocalDateTime
import javax.persistence.Entity
@Entity
data class CanCoupon(
val couponName: String,
val can: Int,
val couponCount: Int,
val validity: LocalDateTime,
val isActive: Boolean,
val isMultipleUse: Boolean
) : BaseEntity()

View File

@ -0,0 +1,107 @@
package kr.co.vividnext.sodalive.can.coupon
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.core.io.InputStreamResource
import org.springframework.data.domain.Pageable
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
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.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@RestController
@RequestMapping("/can/coupon")
class CanCouponController(private val service: CanCouponService) {
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
fun generateCoupon(
@RequestBody request: GenerateCanCouponRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.generateCoupon(request))
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
fun getCouponList(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.getCouponList(offset = pageable.offset, limit = pageable.pageSize.toLong()))
}
@GetMapping("/number-list")
@PreAuthorize("hasRole('ADMIN')")
fun getCouponNumberList(
@RequestParam couponId: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(
service.getCouponNumberList(
couponId = couponId,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
@GetMapping("/number-list/download")
@PreAuthorize("hasRole('ADMIN')")
fun downloadCouponNumberList(
@RequestParam couponId: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val fileName = "쿠폰번호리스트.xlsx"
val encodedFileName = URLEncoder.encode(
fileName,
StandardCharsets.UTF_8.toString()
).replace("+", "%20")
val contentDisposition = "attachment; filename*=UTF-8''$encodedFileName"
val headers = HttpHeaders().apply {
add(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
}
val response = service.downloadCouponNumberList(couponId)
ResponseEntity.ok()
.headers(headers)
.contentType(
MediaType
.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
)
.body(InputStreamResource(response))
}
@PostMapping("/use")
fun useCanCoupon(
@RequestBody request: UseCanCouponRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(
service.useCanCoupon(
couponNumber = request.couponNumber,
memberId = member.id!!
)
)
}
}

View File

@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.can.coupon
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.stereotype.Service
@Service
class CanCouponIssueService(private val couponNumberRepository: CanCouponNumberRepository) {
fun validateAvailableUseCoupon(couponNumber: String, memberId: Long) {
val canCouponNumber = checkCanCouponNumber(couponNumber)
if (!isMultipleUse(canCouponNumber)) {
val canCouponNumberList = couponNumberRepository.findByMemberId(memberId = memberId)
if (canCouponNumberList.isNotEmpty()) {
throw SodaException("해당 쿠폰은 1회만 충전이 가능합니다.")
}
}
}
private fun checkCanCouponNumber(couponNumber: String): CanCouponNumber {
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
if (canCouponNumber.member != null) {
throw SodaException("이미 사용한 쿠폰번호 입니다.")
}
return canCouponNumber
}
private fun isMultipleUse(canCouponNumber: CanCouponNumber) = canCouponNumber.canCoupon!!.isMultipleUse
}

View File

@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.can.coupon
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToOne
@Entity
data class CanCouponNumber(
val couponNumber: String
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "can_coupon_id", nullable = false)
var canCoupon: CanCoupon? = null
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = true)
var member: Member? = null
}

View File

@ -0,0 +1,88 @@
package kr.co.vividnext.sodalive.can.coupon
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.coupon.QCanCouponNumber.canCouponNumber
import org.springframework.data.jpa.repository.JpaRepository
interface CanCouponNumberRepository : JpaRepository<CanCouponNumber, Long>, CanCouponNumberQueryRepository
interface CanCouponNumberQueryRepository {
fun getUseCouponCount(id: Long): Int
fun getCouponNumberTotalCount(couponId: Long): Int
fun getCouponNumberList(couponId: Long, offset: Long, limit: Long): List<GetCouponNumberListItemResponse>
fun getAllCouponNumberList(couponId: Long): List<GetCouponNumberListItemResponse>
fun findByCouponNumber(couponNumber: String): CanCouponNumber?
fun findByMemberId(memberId: Long): List<Long>
}
class CanCouponNumberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanCouponNumberQueryRepository {
override fun getUseCouponCount(id: Long): Int {
return queryFactory
.select(canCouponNumber.id)
.from(canCouponNumber)
.where(canCouponNumber.member.isNotNull)
.fetch()
.size
}
override fun getCouponNumberTotalCount(couponId: Long): Int {
return queryFactory
.select(canCouponNumber.id)
.from(canCouponNumber)
.where(canCouponNumber.canCoupon.id.eq(couponId))
.fetch()
.size
}
override fun getCouponNumberList(couponId: Long, offset: Long, limit: Long): List<GetCouponNumberListItemResponse> {
return queryFactory
.select(
QGetCouponNumberListItemResponse(
canCouponNumber.id,
canCouponNumber.couponNumber,
canCouponNumber.member.isNotNull
)
)
.from(canCouponNumber)
.where(canCouponNumber.canCoupon.id.eq(couponId))
.orderBy(canCouponNumber.id.asc())
.offset(offset)
.limit(limit)
.fetch()
}
override fun getAllCouponNumberList(couponId: Long): List<GetCouponNumberListItemResponse> {
return queryFactory
.select(
QGetCouponNumberListItemResponse(
canCouponNumber.id,
canCouponNumber.couponNumber,
canCouponNumber.member.isNotNull
)
)
.from(canCouponNumber)
.where(canCouponNumber.canCoupon.id.eq(couponId))
.orderBy(canCouponNumber.id.asc())
.fetch()
}
override fun findByCouponNumber(couponNumber: String): CanCouponNumber? {
return queryFactory
.selectFrom(canCouponNumber)
.where(canCouponNumber.couponNumber.eq(couponNumber))
.fetchFirst()
}
override fun findByMemberId(memberId: Long): List<Long> {
return queryFactory
.select(canCouponNumber.id)
.from(canCouponNumber)
.where(canCouponNumber.member.id.eq(memberId))
.fetch()
}
}

View File

@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.can.coupon
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.coupon.QCanCoupon.canCoupon
import org.springframework.data.jpa.repository.JpaRepository
interface CanCouponRepository : JpaRepository<CanCoupon, Long>, CanCouponQueryRepository
interface CanCouponQueryRepository {
fun getCouponTotalCount(): Int
fun getCouponList(offset: Long, limit: Long): List<CanCoupon>
}
class CanCouponQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanCouponQueryRepository {
override fun getCouponTotalCount(): Int {
return queryFactory
.select(canCoupon.id)
.from(canCoupon)
.fetch()
.size
}
override fun getCouponList(offset: Long, limit: Long): List<CanCoupon> {
return queryFactory
.selectFrom(canCoupon)
.orderBy(canCoupon.id.desc())
.offset(offset)
.limit(limit)
.fetch()
}
}

View File

@ -0,0 +1,116 @@
package kr.co.vividnext.sodalive.can.coupon
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.sqs.SqsEvent
import kr.co.vividnext.sodalive.aws.sqs.SqsEventType
import kr.co.vividnext.sodalive.can.charge.ChargeService
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.MemberRepository
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.time.format.DateTimeFormatter
@Service
class CanCouponService(
private val issueService: CanCouponIssueService,
private val chargeService: ChargeService,
private val repository: CanCouponRepository,
private val couponNumberRepository: CanCouponNumberRepository,
private val memberRepository: MemberRepository,
private val objectMapper: ObjectMapper,
private val applicationEventPublisher: ApplicationEventPublisher
) {
fun generateCoupon(request: GenerateCanCouponRequest) {
val message = objectMapper.writeValueAsString(request)
applicationEventPublisher.publishEvent(SqsEvent(type = SqsEventType.GENERATE_COUPON, message = message))
}
fun getCouponList(offset: Long, limit: Long): GetCouponListResponse {
val totalCount = repository.getCouponTotalCount()
val items = repository.getCouponList(offset = offset, limit = limit)
.asSequence()
.map {
val useCouponCount = couponNumberRepository.getUseCouponCount(id = it.id!!)
GetCouponListItemResponse(
id = it.id!!,
couponName = it.couponName,
can = it.can,
couponCount = it.couponCount,
useCouponCount = useCouponCount,
validity = it.validity.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")),
isMultipleUse = it.isMultipleUse,
isActive = it.isActive
)
}
.toList()
return GetCouponListResponse(totalCount, items)
}
fun getCouponNumberList(couponId: Long, offset: Long, limit: Long): GetCouponNumberListResponse {
val totalCount = couponNumberRepository.getCouponNumberTotalCount(couponId = couponId)
val items = couponNumberRepository.getCouponNumberList(couponId = couponId, offset = offset, limit = limit)
return GetCouponNumberListResponse(totalCount, items)
}
fun downloadCouponNumberList(couponId: Long): ByteArrayInputStream {
val header = listOf("순번", "쿠폰번호", "사용여부")
val byteArrayOutputStream = ByteArrayOutputStream()
val couponNumberList = couponNumberRepository.getAllCouponNumberList(couponId)
val workbook = XSSFWorkbook()
try {
val sheet = workbook.createSheet()
val row = sheet.createRow(0)
header.forEachIndexed { index, string ->
val cell = row.createCell(index)
cell.setCellValue(string)
}
couponNumberList.forEachIndexed { index, item ->
val couponNumberRow = sheet.createRow(index + 1)
couponNumberRow.createCell(0).setCellValue(item.couponNumberId.toString())
couponNumberRow.createCell(1).setCellValue(insertHyphens(item.couponNumber))
couponNumberRow.createCell(2).setCellValue(
if (item.isUsed) {
"O"
} else {
"X"
}
)
}
workbook.write(byteArrayOutputStream)
return ByteArrayInputStream(byteArrayOutputStream.toByteArray())
} catch (e: IOException) {
throw SodaException("다운로드를 하지 못했습니다.\n다시 시도해 주세요.")
} finally {
workbook.close()
byteArrayOutputStream.close()
}
}
fun useCanCoupon(couponNumber: String, memberId: Long) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("쿠폰은 본인인증을 하셔야 사용이 가능합니다.")
issueService.validateAvailableUseCoupon(couponNumber, memberId)
chargeService.chargeByCoupon(couponNumber, member)
}
private fun insertHyphens(input: String): String {
return input.chunked(4).joinToString("-")
}
}

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.can.coupon
import com.fasterxml.jackson.annotation.JsonProperty
data class GenerateCanCouponRequest(
@JsonProperty("couponName") val couponName: String,
@JsonProperty("can") val can: Int,
@JsonProperty("validity") val validity: String,
@JsonProperty("isMultipleUse") val isMultipleUse: Boolean,
@JsonProperty("couponNumberCount") val couponNumberCount: Int
)

View File

@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.can.coupon
data class GetCouponListResponse(
val totalCount: Int,
val items: List<GetCouponListItemResponse>
)
data class GetCouponListItemResponse(
val id: Long,
val couponName: String,
val can: Int,
val couponCount: Int,
val useCouponCount: Int,
val validity: String,
val isMultipleUse: Boolean,
val isActive: Boolean
)

View File

@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.can.coupon
import com.querydsl.core.annotations.QueryProjection
data class GetCouponNumberListResponse(
val totalCount: Int,
val items: List<GetCouponNumberListItemResponse>
)
data class GetCouponNumberListItemResponse @QueryProjection constructor(
val couponNumberId: Long,
val couponNumber: String,
val isUsed: Boolean
)

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.can.coupon
data class UseCanCouponRequest(val couponNumber: String)

View File

@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.configs
import com.amazonaws.auth.AWSStaticCredentialsProvider
import com.amazonaws.auth.BasicAWSCredentials
import com.amazonaws.services.sqs.AmazonSQS
import com.amazonaws.services.sqs.AmazonSQSClientBuilder
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class AmazonSQSConfig(
@Value("\${cloud.aws.credentials.access-key}")
private val accessKey: String,
@Value("\${cloud.aws.credentials.secret-key}")
private val secretKey: String,
@Value("\${cloud.aws.region.static}")
private val region: String
) {
@Bean
fun amazonSQS(): AmazonSQS {
val basicAWSCredentials = BasicAWSCredentials(accessKey, secretKey)
val awsStaticCredentialsProvider = AWSStaticCredentialsProvider(basicAWSCredentials)
return AmazonSQSClientBuilder.standard()
.withCredentials(awsStaticCredentialsProvider)
.withRegion(region)
.build()
}
}

View File

@ -37,6 +37,8 @@ cloud:
keyPairId: ${CONTENT_CLOUD_FRONT_KEY_PAIR_ID}
cloudFront:
host: ${CLOUD_FRONT_HOST}
sqs:
generateCouponUrl: ${AMAZON_SQS_GENERATE_CAN_COUPON_QUEUE_URL}
region:
static: ap-northeast-2
stack: