Compare commits

...

95 Commits

Author SHA1 Message Date
klaus d55514e3a7 Merge pull request 'test' (#1) from test into main
Reviewed-on: #1
2023-08-16 02:30:36 +00:00
Klaus 9236aece18 manager -> creator 2023-08-15 00:12:27 +09:00
Klaus c84a9bc473 라이브 정보 - 필요없는 부분 제거 2023-08-14 22:49:41 +09:00
Klaus ba69f86295 라이브 상세 - dateformat 원상복구 2023-08-14 19:14:51 +09:00
Klaus 6f40838d09 라이브 상세 - dateformat에 locale 추가 2023-08-14 18:58:16 +09:00
Klaus 4bf8617102 coin -> can 으로 변경 2023-08-13 20:24:48 +09:00
Klaus c8764be69f coin -> can 으로 변경 2023-08-13 19:47:28 +09:00
Klaus c9970ce7ca 불필요한 logger 제거 2023-08-09 12:17:37 +09:00
Klaus c876378b21 푸시 발송 대상 유저 조회 로직 수정 2023-08-09 11:56:15 +09:00
Klaus 0fa16762bd debug logger 2023-08-09 10:59:36 +09:00
Klaus e5531bbef7 debug logger 2023-08-09 10:43:39 +09:00
Klaus ccb32a73dd 푸시 발송 이벤트 - container가 필요한 부분에 container 추가 2023-08-09 10:36:40 +09:00
Klaus 2548c92aa1 특정 유저 푸시 적용 2023-08-09 10:29:08 +09:00
Klaus b331e6e152 TransactionalEventListener -> EventListener 변경 2023-08-09 10:13:28 +09:00
Klaus 11f1f781a0 EventListener -> TransactionalEventListener 변경 2023-08-09 10:06:06 +09:00
Klaus eb09251955 debug logger 추가 2023-08-09 09:16:27 +09:00
Klaus 777f4755be 팔로잉 채널 전체보기 API 추가 2023-08-09 07:48:43 +09:00
Klaus ba3444fd26 라이브 메인 - 팔로잉 채널 API 추가 2023-08-09 07:14:54 +09:00
Klaus e9723d37ba 차단유저 필터링 로직 추가 2023-08-09 06:54:25 +09:00
Klaus 705bf0b6b2 푸시메시지 기능 추가 - 전체, 개별, 라이브 생성, 라이브 시작, 메시지 전송, 콘텐츠 업로드 2023-08-08 16:46:30 +09:00
Klaus 771dbeced0 레디스 JWT 토큰 저장소 - tokenList 가 계속 null 이 되면서 초기화가 되지 않아 set 으로 변경하고 default value 를 이용한 초기화로 변경 2023-08-08 12:47:49 +09:00
Klaus f7cdf40976 관리자 - 탐색 메뉴 API 추가 2023-08-07 18:20:00 +09:00
Klaus 14220ff6dc 관리자 - 회원 타입 수정 기능 추가 2023-08-07 15:36:22 +09:00
Klaus b556219e36 라이브 입장 - 퇴장 횟수 체크, 차단된 유저 체크 2023-08-07 14:47:00 +09:00
Klaus 6ff38decab account -> member 2023-08-07 14:46:31 +09:00
Klaus 872e84baf1 토큰저장소 -> mutableList로 변경 2023-08-07 14:36:50 +09:00
Klaus 581b6975a3 토큰저장소 -> mutableList로 변경 2023-08-07 14:30:49 +09:00
Klaus b99ab9103d ReportType - 사용하지 않는 Review 제거 2023-08-07 14:18:52 +09:00
Klaus 34590347a6 관리자 - 추천 라이브 크리에이터 API 2023-08-07 03:09:44 +09:00
Klaus 14b25bdfc3 관리자 - 콘텐츠 리스트, 콘텐츠 배너관리, 콘텐츠 큐레이션 관리 API 2023-08-07 01:23:42 +09:00
Klaus 7696f06fbd 관리자 - 라이브 리스트 API 2023-08-06 22:50:57 +09:00
Klaus 38f6e8d870 관리자 - 라이브 관심사 등록/수정/삭제 API 2023-08-06 22:42:50 +09:00
Klaus 12c9b14168 관리자 - 충전 이벤트 API 2023-08-06 22:30:46 +09:00
Klaus dc299f7727 관리자 - 이벤트 API 2023-08-06 22:18:45 +09:00
Klaus a983595bad 관리자 - FAQ API 2023-08-06 22:10:33 +09:00
Klaus 87a5ceee9c 관리자 - 개인정보처리방침, 이용약관 API 2023-08-06 22:03:25 +09:00
Klaus 3d514e8ad4 관리자 - 크리에이터 리스트 API 2023-08-06 14:40:44 +09:00
Klaus 94551b05ff 관리자 - 캔, 충전현황 API 2023-08-06 14:29:36 +09:00
Klaus 841e32a50b 관리자 - 회원 태그 API 2023-08-06 14:01:09 +09:00
Klaus cbcc63dc71 관리자 - 회원리스트 API 2023-08-06 11:13:27 +09:00
Klaus 9b4e2fd192 cloudfront - https 제거 2023-08-05 00:58:45 +09:00
Klaus c9a9c8c310 새로운 콘텐츠를 올린 크리에이터 - 프로필 이미지가 null인 버그 수정 2023-08-05 00:15:05 +09:00
Klaus fcd435f470 관리자 - 콘텐츠 테마 API 2023-08-04 22:42:14 +09:00
Klaus 88df83fdc0 메뉴 API 2023-08-04 22:32:34 +09:00
Klaus 03d782850c 로그인 - MemberToken init 방식 수정 2023-08-04 21:51:46 +09:00
Klaus a009a728a8 jsr305 추가 2023-08-04 01:51:00 +09:00
Klaus b12fba992c 콘텐츠 업로드 완료 - 권한추가 2023-08-04 01:45:56 +09:00
Klaus 1fe5309fdc 콘텐츠 API 추가 2023-08-03 20:36:37 +09:00
Klaus 5d6eb5da4f 신고 API 추가 2023-08-03 00:24:17 +09:00
Klaus 472b8d36f5 코인 소비 로직 수정 2023-08-02 19:30:53 +09:00
Klaus 25b3bcb534 라이브 예약 취소 API 2023-08-02 19:11:25 +09:00
Klaus 980faae943 회원탈퇴 API - 회원탈퇴시 토큰 삭제 2023-08-02 17:09:08 +09:00
Klaus 234e02dca4 회원탈퇴 API - 회원탈퇴시 토큰 삭제 2023-08-02 17:04:51 +09:00
Klaus d9f6ac01f4 회원탈퇴 API 추가 2023-08-02 16:57:26 +09:00
Klaus baad5653e8 서비스 공지사항 API 추가 2023-08-02 16:46:56 +09:00
Klaus 0c106540cd 모든 기기에서 로그아웃 추가 2023-08-02 15:54:25 +09:00
Klaus 16c5c5f6b6 로그아웃 추가
서블릿 필터에서 Exception 발생시 처리
2023-08-02 15:46:02 +09:00
Klaus fff8037277 최근 방문한 라이브 유저 검색이 되지 않는 버그 수정 2023-08-02 14:28:24 +09:00
Klaus 4a7e684606 최근 방문한 라이브 유저 검색이 되지 않는 버그 수정 2023-08-02 14:20:57 +09:00
Klaus bc0baeffe9 유저 검색 - 차단된 유저만 검색이 되는 버그 수정 2023-08-02 14:14:45 +09:00
Klaus b3d72ead1f 메시지 API 2023-08-02 14:04:31 +09:00
Klaus c25b105d4d 크리에이터 채널 API 2023-08-01 14:40:52 +09:00
Klaus 049e1c41de 탐색 메인 - API 추가 2023-08-01 10:23:49 +09:00
Klaus df861bf8a1 라이브 방 정보 - 방 나가기 로직 @Transactional 추가 2023-08-01 06:32:21 +09:00
Klaus 7d904067fe 라이브 방 정보 - 방 나가기 로직 디버그 하기 위해 print 코드 추가 2023-08-01 06:25:05 +09:00
Klaus 018a4a95a2 라이브 방 정보 - 방 나가기 로직 수정 2023-08-01 06:14:53 +09:00
Klaus 3c09047371 라이브 방 정보 - 방 나가기 로직 수정 2023-08-01 06:08:23 +09:00
Klaus 5a56990d0b 라이브 방 정보 - ReentrantReadWriteLock 추가 2023-08-01 05:48:06 +09:00
Klaus 7671e24470 라이브 방 - 라이브 나가기 로직 수정 2023-08-01 05:39:14 +09:00
Klaus 3cac42b5b9 라이브 방 - 라이브 참여자 프로필 이미지에 cloudfront host 추가 2023-08-01 05:24:35 +09:00
Klaus fb1386be05 라이브 방 - 라이브 참여자 프로필 이미지에 cloudfront host 추가 2023-08-01 05:18:21 +09:00
Klaus 58a7f87ffd 라이브 방 - 아고라 설정 및 라이브 방 관련 API 2023-08-01 04:56:47 +09:00
Klaus f393c7630e 라이브 - 시작, 취소, 입장, 수정, 예약 API 추가 2023-07-31 17:09:45 +09:00
Klaus 197cca1f1b 라이브 만들기 - Transactional 설정 2023-07-31 03:04:09 +09:00
Klaus 9545ab0789 라이브 - 추천 라이브, 추천 채널 이미지 주소에 CDN host 추가 2023-07-31 02:13:05 +09:00
Klaus 036107d103 라이브 - 방 만들기, 태그 등록, 태그 조회 API 추가 2023-07-31 02:04:32 +09:00
Klaus f1610af6f6 코인 충전내역 수정 2023-07-29 05:57:07 +09:00
Klaus 7c8084bdd4 코인 충전, 코인 내역 API 추가 2023-07-29 05:39:17 +09:00
Klaus c06de5f9f6 코인 -> 캔 으로 변경 2023-07-29 04:15:22 +09:00
Klaus a3b3aa5b18 마이페이지 메인 - 본인인증 true/false 정보 추가 2023-07-28 18:09:06 +09:00
Klaus 5b3b3a767f 본인인증 추가 2023-07-28 17:35:31 +09:00
Klaus ab116bb45b 마이페이지 메인 - 마이페이지 조회 API 추가 2023-07-28 14:38:53 +09:00
Klaus 6174ec3523 라이브 메인 - 예약중인 라이브 정렬 오류 수정 2023-07-27 06:42:35 +09:00
Klaus ee99dd3147 라이브 메인 - 추천라이브, 추천채널, 예약중인 라이브, 진행중인 라이브, 이벤트 배너 API 추가 2023-07-27 06:24:23 +09:00
Klaus ee124e258e 유저 푸시토큰 업데이트 API 추가 2023-07-25 03:05:54 +09:00
Klaus 0580cdd2d6 회원가입 유효성 검사 컴포넌트 - 패키지 이동 2023-07-24 16:12:05 +09:00
Klaus 967d358a52 gradle 설정 - daemon, configureondemand true로 변경 2023-07-24 14:50:09 +09:00
Klaus ac09de9141 회원가입 후 초기 알림설정 기능 추가 2023-07-24 14:40:41 +09:00
Klaus 53a64d9bd7 hibernate-dialect = org.hibernate.dialect.MySQL8Dialect 2023-07-24 03:43:10 +09:00
Klaus f525f19530 테스트용 설정 분리 2023-07-24 02:17:00 +09:00
Klaus f81f07bd05 시큐리티 설정
유저 API - 로그인, 회원가입, 계정정보 추가
2023-07-23 03:28:06 +09:00
Klaus 23506e79f1 application.yml로 파일명 변경 2023-07-21 20:22:14 +09:00
Klaus 9265dc7f6a lint 적용 2023-07-21 19:54:50 +09:00
Klaus 72a94bb311 lint 적용 2023-07-21 19:49:34 +09:00
Klaus 1b43baf12d CodeDeploy 스크립트 추가 2023-07-21 19:45:23 +09:00
349 changed files with 16381 additions and 36 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
HELP.md
.gradle
.envrc
build/
!**/src/main/**/build/
!**/src/test/**/build/

18
appspec.yml Normal file
View File

@ -0,0 +1,18 @@
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user
overwrite: yes
hooks:
ApplicationStart:
- location: scripts/run_process.sh # ApplicationStart 단계에서 해당 파일을 실행해라
timeout: 60
runas: ec2-user
ApplicationStop:
- location: scripts/kill_process.sh # ApplicationStart 단계에서 해당 파일을 실행해라
timeout: 100
runas: ec2-user

View File

@ -1,8 +1,8 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.7.14"
id("io.spring.dependency-management") version "1.0.15.RELEASE"
id("org.springframework.boot") version "2.7.14"
id("io.spring.dependency-management") version "1.0.15.RELEASE"
val kotlinVersion = "1.6.21"
kotlin("jvm") version kotlinVersion
@ -18,42 +18,68 @@ version = "0.0.1-SNAPSHOT"
val querydslVersion = "5.0.0"
java {
sourceCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_11
}
repositories {
mavenCentral()
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
// querydsl (추가 설정)
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")
kapt("org.springframework.boot:spring-boot-configuration-processor")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
// aws
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")
// bootpay
implementation("io.github.bootpay:backend:2.2.1")
implementation("com.squareup.okhttp3:okhttp:4.9.3")
implementation("org.json:json:20230227")
implementation("com.google.code.findbugs:jsr305:3.0.2")
// firebase admin sdk
implementation("com.google.firebase:firebase-admin:9.2.0")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
}
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "11"
}
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "11"
}
}
tasks.withType<Test> {
useJUnitPlatform()
useJUnitPlatform()
}
tasks.getByName<Jar>("jar") {

View File

@ -4,3 +4,5 @@ distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
org.gradle.daemon=true
org.gradle.configureondemand=true

16
scripts/kill_process.sh Normal file
View File

@ -0,0 +1,16 @@
#!/bin/bash
BUILD_JAR=$(ls /home/ec2-user/build/libs/*.jar) # jar가 위치하는 곳
JAR_NAME=$(basename $BUILD_JAR)
echo "> build 파일명: $JAR_NAME" >> /home/ec2-user/deploy.log
echo "> 현재 실행중인 애플리케이션 pid 확인" >> /home/ec2-user/deploy.log
CURRENT_PID=$(pgrep -f $JAR_NAME)
if [ -z $CURRENT_PID ]
then
echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다." >> /home/ec2-user/deploy.log
else
echo "> kill -15 $CURRENT_PID"
kill -15 $CURRENT_PID
sleep 5
fi

12
scripts/run_process.sh Normal file
View File

@ -0,0 +1,12 @@
#!/bin/bash
BUILD_JAR=$(ls /home/ec2-user/build/libs/*.jar) # jar가 위치하는 곳
JAR_NAME=$(basename $BUILD_JAR)
echo "> build 파일 복사" >> /home/ec2-user/deploy.log
DEPLOY_PATH=/home/ec2-user/
cp $BUILD_JAR $DEPLOY_PATH
DEPLOY_JAR=$DEPLOY_PATH$JAR_NAME
echo "> DEPLOY_JAR 배포" >> /home/ec2-user/deploy.log
chmod +x $DEPLOY_JAR
nohup java -jar $DEPLOY_JAR >> /home/ec2-user/deploy.log 2> /dev/null < /dev/null &

View File

@ -2,10 +2,12 @@ package kr.co.vividnext.sodalive
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableAsync
@SpringBootApplication
class SodaliveApplication
@EnableAsync
class SodaLiveApplication
fun main(args: Array<String>) {
runApplication<SodaliveApplication>(*args)
runApplication<SodaLiveApplication>(*args)
}

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.admin.can
data class AdminCanChargeRequest(
val memberId: Long,
val method: String,
val can: Int
)

View File

@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/admin/can")
@PreAuthorize("hasRole('ADMIN')")
class AdminCanController(private val service: AdminCanService) {
@PostMapping
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
@DeleteMapping("/{id}")
fun deleteCan(@PathVariable id: Long) = ApiResponse.ok(service.deleteCan(id))
@PostMapping("/charge")
fun charge(@RequestBody request: AdminCanChargeRequest) = ApiResponse.ok(service.charge(request))
}

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.can.Can
import org.springframework.data.jpa.repository.JpaRepository
interface AdminCanRepository : JpaRepository<Can, Long>

View File

@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.can.Can
import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.extensions.moneyFormat
data class AdminCanRequest(
val can: Int,
val rewardCan: Int,
val price: Int
) {
fun toEntity(): Can {
var title = "${can.moneyFormat()}"
if (rewardCan > 0) {
title = "$title + ${rewardCan.moneyFormat()}"
}
return Can(
title = title,
can = can,
rewardCan = rewardCan,
price = price,
status = CanStatus.SALE
)
}
}

View File

@ -0,0 +1,56 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.moneyFormat
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminCanService(
private val repository: AdminCanRepository,
private val chargeRepository: ChargeRepository,
private val memberRepository: AdminMemberRepository
) {
@Transactional
fun saveCan(request: AdminCanRequest) {
repository.save(request.toEntity())
}
@Transactional
fun deleteCan(id: Long) {
val can = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 요청입니다.")
can.status = CanStatus.END_OF_SALE
}
@Transactional
fun charge(request: AdminCanChargeRequest) {
val member = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 회원번호 입니다.")
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
charge.title = "${request.can.moneyFormat()}"
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
payment.method = request.method
charge.payment = payment
chargeRepository.save(charge)
member.pgRewardCan += charge.rewardCan
}
}

View File

@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.admin.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/charge/status")
class AdminChargeStatusController(private val service: AdminChargeStatusService) {
@GetMapping
fun getChargeStatus(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
) = ApiResponse.ok(service.getChargeStatus(startDateStr, endDateStr))
@GetMapping("/detail")
fun getChargeStatusDetail(
@RequestParam startDateStr: String,
@RequestParam paymentGateway: PaymentGateway
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
}

View File

@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.QCan.can1
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
LocalDateTime::class.java,
"CONVERT_TZ({0},{1},{2})",
charge.createdAt,
"UTC",
"Asia/Seoul"
),
"%Y-%m-%d"
)
return queryFactory
.select(
QGetChargeStatusQueryDto(
formattedDate,
payment.price.sum(),
can1.price.sum(),
payment.id.count(),
payment.paymentGateway
)
)
.from(payment)
.innerJoin(payment.charge, charge)
.leftJoin(charge.can, can1)
.where(
charge.createdAt.goe(startDate)
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
)
.groupBy(formattedDate, payment.paymentGateway)
.orderBy(formattedDate.desc())
.fetch()
}
fun getChargeStatusDetail(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusDetailQueryDto> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
LocalDateTime::class.java,
"CONVERT_TZ({0},{1},{2})",
charge.createdAt,
"UTC",
"Asia/Seoul"
),
"%Y-%m-%d %H:%i:%s"
)
return queryFactory
.select(
QGetChargeStatusDetailQueryDto(
member.id,
member.nickname,
payment.method.coalesce(""),
payment.price,
can1.price,
formattedDate
)
)
.from(charge)
.innerJoin(charge.member, member)
.innerJoin(charge.payment, payment)
.leftJoin(charge.can, can1)
.where(
charge.createdAt.goe(startDate)
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
)
.orderBy(formattedDate.desc())
.fetch()
}
}

View File

@ -0,0 +1,101 @@
package kr.co.vividnext.sodalive.admin.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository) {
fun getChargeStatus(startDateStr: String, endDateStr: String): List<GetChargeStatusResponse> {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val endDate = LocalDate.parse(endDateStr, dateTimeFormatter).atTime(23, 59, 59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
var totalChargeAmount = 0
var totalChargeCount = 0L
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
.asSequence()
.map {
val chargeAmount = if (it.paymentGateWay == PaymentGateway.APPLE_IAP) {
it.appleChargeAmount.toInt()
} else {
it.pgChargeAmount
}
val chargeCount = it.chargeCount
totalChargeAmount += chargeAmount
totalChargeCount += chargeCount
GetChargeStatusResponse(
date = it.date,
chargeAmount = chargeAmount,
chargeCount = chargeCount,
pg = it.paymentGateWay.name
)
}
.toMutableList()
chargeStatusList.add(
0,
GetChargeStatusResponse(
date = "합계",
chargeAmount = totalChargeAmount,
chargeCount = totalChargeCount,
pg = ""
)
)
return chargeStatusList.toList()
}
fun getChargeStatusDetail(
startDateStr: String,
paymentGateway: PaymentGateway
): List<GetChargeStatusDetailResponse> {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val endDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(23, 59, 59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
return repository.getChargeStatusDetail(startDate, endDate)
.asSequence()
.filter {
if (paymentGateway == PaymentGateway.APPLE_IAP) {
it.appleChargeAmount > 0
} else {
it.pgChargeAmount > 0
}
}
.map {
GetChargeStatusDetailResponse(
memberId = it.memberId,
nickname = it.nickname,
method = it.method,
amount = if (paymentGateway == PaymentGateway.APPLE_IAP) {
it.appleChargeAmount.toInt()
} else {
it.pgChargeAmount
},
datetime = it.datetime
)
}
.toList()
}
}

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val method: String,
val appleChargeAmount: Double,
val pgChargeAmount: Int,
val datetime: String
)

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.charge
data class GetChargeStatusDetailResponse(
val memberId: Long,
val nickname: String,
val method: String,
val amount: Int,
val datetime: String
)

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
data class GetChargeStatusQueryDto @QueryProjection constructor(
val date: String,
val appleChargeAmount: Double,
val pgChargeAmount: Int,
val chargeCount: Long,
val paymentGateWay: PaymentGateway
)

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.admin.charge
data class GetChargeStatusResponse(
val date: String,
val chargeAmount: Int,
val chargeCount: Long,
val pg: String
)

View File

@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.admin.content
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.PutMapping
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
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/audio-content")
class AdminContentController(private val service: AdminContentService) {
@GetMapping("/list")
fun getAudioContentList(pageable: Pageable) = ApiResponse.ok(service.getAudioContentList(pageable))
@GetMapping("/search")
fun searchAudioContent(
@RequestParam(value = "search_word") searchWord: String,
pageable: Pageable
) = ApiResponse.ok(service.searchAudioContent(searchWord, pageable))
@PutMapping
fun modifyAudioContent(
@RequestBody request: UpdateAdminContentRequest
) = ApiResponse.ok(service.updateAudioContent(request))
}

View File

@ -0,0 +1,119 @@
package kr.co.vividnext.sodalive.admin.content
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.content.AudioContent
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.hashtag.QAudioContentHashTag.audioContentHashTag
import kr.co.vividnext.sodalive.content.hashtag.QHashTag.hashTag
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
interface AdminContentRepository : JpaRepository<AudioContent, Long>, AdminAudioContentQueryRepository
interface AdminAudioContentQueryRepository {
fun getAudioContentTotalCount(searchWord: String = ""): Int
fun getAudioContentList(offset: Long, limit: Long, searchWord: String = ""): List<GetAdminContentListItem>
fun getHashTagList(audioContentId: Long): List<String>
}
class AdminAudioContentQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AdminAudioContentQueryRepository {
override fun getAudioContentTotalCount(searchWord: String): Int {
var where = audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContent.isActive.isTrue)
if (searchWord.trim().length > 1) {
where = where.and(
audioContent.title.contains(searchWord)
.or(audioContent.member.nickname.contains(searchWord))
)
}
return queryFactory
.select(audioContent.id)
.from(audioContent)
.where(where)
.fetch()
.size
}
override fun getAudioContentList(offset: Long, limit: Long, searchWord: String): List<GetAdminContentListItem> {
var where = audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContent.isActive.isTrue)
if (searchWord.trim().length > 1) {
where = where.and(
audioContent.title.contains(searchWord)
.or(audioContent.member.nickname.contains(searchWord))
)
}
return queryFactory
.select(
QGetAdminContentListItem(
audioContent.id,
audioContent.title,
audioContent.detail,
audioContentCuration.title,
audioContentCuration.id.nullif(0),
audioContent.coverImage,
audioContent.member!!.nickname,
audioContentTheme.theme,
audioContent.price,
audioContent.isAdult,
audioContent.duration,
audioContent.content,
formattedDateExpression(audioContent.createdAt)
)
)
.from(audioContent)
.leftJoin(audioContent.curation, audioContentCuration)
.innerJoin(audioContent.theme, audioContentTheme)
.where(where)
.offset(offset)
.limit(limit)
.orderBy(audioContent.id.desc())
.fetch()
}
override fun getHashTagList(audioContentId: Long): List<String> {
return queryFactory
.select(hashTag.tag)
.from(audioContentHashTag)
.innerJoin(audioContentHashTag.hashTag, hashTag)
.innerJoin(audioContentHashTag.audioContent, audioContent)
.where(
audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
)
.fetch()
}
private fun formattedDateExpression(
dateTime: DateTimePath<LocalDateTime>,
format: String = "%Y-%m-%d"
): StringTemplate {
return Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
LocalDateTime::class.java,
"CONVERT_TZ({0},{1},{2})",
dateTime,
"UTC",
"Asia/Seoul"
),
format
)
}
}

View File

@ -0,0 +1,121 @@
package kr.co.vividnext.sodalive.admin.content
import kr.co.vividnext.sodalive.admin.content.curation.AdminContentCurationRepository
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminContentService(
private val repository: AdminContentRepository,
private val audioContentCloudFront: AudioContentCloudFront,
private val curationRepository: AdminContentCurationRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String
) {
fun getAudioContentList(pageable: Pageable): GetAdminContentListResponse {
val totalCount = repository.getAudioContentTotalCount()
val audioContentAndThemeList = repository.getAudioContentList(
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
val audioContentList = audioContentAndThemeList
.asSequence()
.map {
val tags = repository
.getHashTagList(audioContentId = it.audioContentId)
.joinToString(" ") { tag -> tag }
it.tags = tags
it
}
.map {
it.contentUrl = audioContentCloudFront.generateSignedURL(
resourcePath = it.contentUrl,
expirationTime = 1000 * 60 * 60 * (it.remainingTime.split(":")[0].toLong() + 2)
)
it
}
.map {
it.coverImageUrl = "$coverImageHost/${it.coverImageUrl}"
it
}
.toList()
return GetAdminContentListResponse(totalCount, audioContentList)
}
fun searchAudioContent(searchWord: String, pageable: Pageable): GetAdminContentListResponse {
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
val totalCount = repository.getAudioContentTotalCount(searchWord)
val audioContentAndThemeList = repository.getAudioContentList(
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
searchWord = searchWord
)
val audioContentList = audioContentAndThemeList
.asSequence()
.map {
val tags = repository
.getHashTagList(audioContentId = it.audioContentId)
.joinToString(" ") { tag -> tag }
it.tags = tags
it
}
.map {
it.contentUrl = audioContentCloudFront.generateSignedURL(
resourcePath = it.contentUrl,
expirationTime = 1000 * 60 * 60 * (it.remainingTime.split(":")[0].toLong() + 2)
)
it
}
.map {
it.coverImageUrl = "$coverImageHost/${it.coverImageUrl}"
it
}
.toList()
return GetAdminContentListResponse(totalCount, audioContentList)
}
@Transactional
fun updateAudioContent(request: UpdateAdminContentRequest) {
val audioContent = repository.findByIdOrNull(id = request.id)
?: throw SodaException("없는 콘텐츠 입니다.")
if (request.isDefaultCoverImage) {
audioContent.coverImage = "profile/default_profile.png"
}
if (request.isActive != null) {
audioContent.isActive = request.isActive
}
if (request.isAdult != null) {
audioContent.isAdult = request.isAdult
}
if (request.isCommentAvailable != null) {
audioContent.isCommentAvailable = request.isCommentAvailable
}
if (request.title != null) {
audioContent.title = request.title
}
if (request.detail != null) {
audioContent.detail = request.detail
}
if (request.curationId != null) {
val curation = curationRepository.findByIdAndActive(id = request.curationId)
audioContent.curation = curation
}
}
}

View File

@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.admin.content
import com.querydsl.core.annotations.QueryProjection
data class GetAdminContentListResponse(
val totalCount: Int,
val items: List<GetAdminContentListItem>
)
data class GetAdminContentListItem @QueryProjection constructor(
val audioContentId: Long,
val title: String,
val detail: String,
val curationTitle: String?,
val curationId: Long,
var coverImageUrl: String,
val creatorNickname: String,
val theme: String,
val price: Int,
val isAdult: Boolean,
val remainingTime: String,
var contentUrl: String,
val date: String
) {
var tags: String = ""
}

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.content
data class UpdateAdminContentRequest(
val id: Long,
val isDefaultCoverImage: Boolean,
val title: String?,
val detail: String?,
val curationId: Long?,
val isAdult: Boolean?,
val isActive: Boolean?,
val isCommentAvailable: Boolean?
)

View File

@ -0,0 +1,37 @@
package kr.co.vividnext.sodalive.admin.content.banner
import kr.co.vividnext.sodalive.common.ApiResponse
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.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/audio-content/banner")
@PreAuthorize("hasRole('ADMIN')")
class AdminContentBannerController(private val service: AdminContentBannerService) {
@PostMapping
fun createAudioContentMainBanner(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.createAudioContentMainBanner(image, requestString))
@PutMapping
fun modifyAudioContentMainBanner(
@RequestPart("image", required = false) image: MultipartFile? = null,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.updateAudioContentMainBanner(image, requestString))
@PutMapping("/orders")
fun updateBannerOrders(
@RequestBody request: UpdateBannerOrdersRequest
) = ApiResponse.ok(service.updateBannerOrders(request.ids), "수정되었습니다.")
@GetMapping
fun getAudioContentMainBannerList() = ApiResponse.ok(service.getAudioContentMainBannerList())
}

View File

@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.event.QEvent.event
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AdminContentBannerQueryRepository
interface AdminContentBannerQueryRepository {
fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse>
}
class AdminContentBannerQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : AdminContentBannerQueryRepository {
override fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse> {
return queryFactory
.select(
QGetAdminContentBannerResponse(
audioContentBanner.id,
audioContentBanner.type,
audioContentBanner.thumbnailImage.prepend("/").prepend(cloudFrontHost),
audioContentBanner.event.id,
audioContentBanner.event.thumbnailImage,
audioContentBanner.creator.id,
audioContentBanner.creator.nickname,
audioContentBanner.link,
audioContentBanner.isAdult
)
)
.from(audioContentBanner)
.leftJoin(audioContentBanner.event, event)
.leftJoin(audioContentBanner.creator, member)
.where(audioContentBanner.isActive.isTrue)
.orderBy(audioContentBanner.orders.asc())
.fetch()
}
}

View File

@ -0,0 +1,144 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.event.EventRepository
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminContentBannerService(
private val s3Uploader: S3Uploader,
private val repository: AdminContentBannerRepository,
private val memberRepository: MemberRepository,
private val eventRepository: EventRepository,
private val objectMapper: ObjectMapper,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@Transactional
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java)
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
throw SodaException("크리에이터를 선택하세요.")
}
if (request.type == AudioContentBannerType.LINK && request.link == null) {
throw SodaException("링크 url을 입력하세요.")
}
if (request.type == AudioContentBannerType.EVENT && request.eventId == null) {
throw SodaException("이벤트를 선택하세요.")
}
val event = if (request.eventId != null && request.eventId > 0) {
eventRepository.findByIdOrNull(request.eventId)
} else {
null
}
val creator = if (request.creatorId != null && request.creatorId > 0) {
memberRepository.findByIdOrNull(request.creatorId)
} else {
null
}
val audioContentBanner = AudioContentBanner(type = request.type)
audioContentBanner.link = request.link
audioContentBanner.isAdult = request.isAdult
audioContentBanner.event = event
audioContentBanner.creator = creator
repository.save(audioContentBanner)
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audio_content_banner/${audioContentBanner.id}/$fileName"
)
audioContentBanner.thumbnailImage = imagePath
}
@Transactional
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java)
val audioContentBanner = repository.findByIdOrNull(request.id)
?: throw SodaException("잘못된 요청입니다.")
if (image != null) {
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audio_content_banner/${audioContentBanner.id}/$fileName"
)
audioContentBanner.thumbnailImage = imagePath
}
if (request.isAdult != null) {
audioContentBanner.isAdult = request.isAdult
}
if (request.isActive != null) {
audioContentBanner.isActive = request.isActive
}
if (request.type != null) {
audioContentBanner.creator = null
audioContentBanner.event = null
audioContentBanner.link = null
if (request.type == AudioContentBannerType.CREATOR) {
if (request.creatorId != null) {
val creator = memberRepository.findByIdOrNull(request.creatorId)
?: throw SodaException("크리에이터를 선택하세요.")
audioContentBanner.creator = creator
} else {
throw SodaException("크리에이터를 선택하세요.")
}
} else if (request.type == AudioContentBannerType.LINK) {
if (request.link != null) {
audioContentBanner.link = request.link
} else {
throw SodaException("링크 url을 입력하세요.")
}
} else if (request.type == AudioContentBannerType.EVENT) {
if (request.eventId != null) {
val event = eventRepository.findByIdOrNull(request.eventId)
?: throw SodaException("이벤트를 선택하세요.")
audioContentBanner.event = event
} else {
throw SodaException("이벤트를 선택하세요.")
}
}
audioContentBanner.type = request.type
}
}
@Transactional
fun updateBannerOrders(ids: List<Long>) {
for (index in ids.indices) {
val tag = repository.findByIdOrNull(ids[index])
if (tag != null) {
tag.orders = index + 1
}
}
}
fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse> {
return repository.getAudioContentMainBannerList()
}
}

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.admin.content.banner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
data class CreateContentBannerRequest(
val type: AudioContentBannerType,
val eventId: Long?,
val creatorId: Long?,
val link: String?,
val isAdult: Boolean
)

View File

@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
data class GetAdminContentBannerResponse @QueryProjection constructor(
val id: Long,
val type: AudioContentBannerType,
val thumbnailImageUrl: String,
val eventId: Long?,
val eventThumbnailImage: String?,
val creatorId: Long?,
val creatorNickname: String?,
val link: String?,
val isAdult: Boolean
)

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.admin.content.banner
data class UpdateBannerOrdersRequest(
val ids: List<Long>
)

View File

@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.admin.content.banner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
data class UpdateContentBannerRequest(
val id: Long,
val type: AudioContentBannerType?,
val eventId: Long?,
val creatorId: Long?,
val link: String?,
val isAdult: Boolean?,
val isActive: Boolean?
)

View File

@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.admin.content.curation
import kr.co.vividnext.sodalive.common.ApiResponse
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
@RequestMapping("/admin/audio-content/curation")
@PreAuthorize("hasRole('ADMIN')")
class AdminContentCurationController(private val service: AdminContentCurationService) {
@PostMapping
fun createContentCuration(
@RequestBody request: CreateContentCurationRequest
) = ApiResponse.ok(service.createContentCuration(request))
@PutMapping
fun updateContentCuration(
@RequestBody request: UpdateContentCurationRequest
) = ApiResponse.ok(service.updateContentCuration(request))
@PutMapping("/orders")
fun updateContentCurationOrders(
@RequestBody request: UpdateContentCurationOrdersRequest
) = ApiResponse.ok(service.updateContentCurationOrders(request.ids), "수정되었습니다.")
@GetMapping
fun getContentCurationList() = ApiResponse.ok(service.getContentCurationList())
}

View File

@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.admin.content.curation
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminContentCurationRepository :
JpaRepository<AudioContentCuration, Long>,
AdminContentCurationQueryRepository
interface AdminContentCurationQueryRepository {
fun getAudioContentCurationList(): List<GetAdminContentCurationResponse>
fun findByIdAndActive(id: Long): AudioContentCuration?
}
@Repository
class AdminContentCurationQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AdminContentCurationQueryRepository {
override fun getAudioContentCurationList(): List<GetAdminContentCurationResponse> {
return queryFactory
.select(
QGetAdminContentCurationResponse(
audioContentCuration.id,
audioContentCuration.title,
audioContentCuration.description,
audioContentCuration.isAdult
)
)
.from(audioContentCuration)
.where(audioContentCuration.isActive.isTrue)
.orderBy(audioContentCuration.orders.asc())
.fetch()
}
override fun findByIdAndActive(id: Long): AudioContentCuration? {
return queryFactory
.selectFrom(audioContentCuration)
.where(
audioContentCuration.id.eq(id)
.and(audioContentCuration.isActive.isTrue)
)
.fetchFirst()
}
}

View File

@ -0,0 +1,60 @@
package kr.co.vividnext.sodalive.admin.content.curation
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminContentCurationService(
private val repository: AdminContentCurationRepository
) {
@Transactional
fun createContentCuration(request: CreateContentCurationRequest) {
repository.save(
AudioContentCuration(
title = request.title,
description = request.description,
isAdult = request.isAdult
)
)
}
@Transactional
fun updateContentCuration(request: UpdateContentCurationRequest) {
val audioContentCuration = repository.findByIdOrNull(id = request.id)
?: throw SodaException("잘못된 요청입니다.")
if (request.title != null) {
audioContentCuration.title = request.title
}
if (request.description != null) {
audioContentCuration.description = request.description
}
if (request.isAdult != null) {
audioContentCuration.isAdult = request.isAdult
}
if (request.isActive != null) {
audioContentCuration.isActive = request.isActive
}
}
@Transactional
fun updateContentCurationOrders(ids: List<Long>) {
for (index in ids.indices) {
val audioContentCuration = repository.findByIdOrNull(ids[index])
if (audioContentCuration != null) {
audioContentCuration.orders = index + 1
}
}
}
fun getContentCurationList(): List<GetAdminContentCurationResponse> {
return repository.getAudioContentCurationList()
}
}

View File

@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.content.curation
data class CreateContentCurationRequest(
val title: String,
val description: String,
val isAdult: Boolean
)
data class UpdateContentCurationRequest(
val id: Long,
val title: String?,
val description: String?,
val isAdult: Boolean?,
val isActive: Boolean?
)
data class UpdateContentCurationOrdersRequest(
val ids: List<Long>
)

View File

@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.admin.content.curation
import com.querydsl.core.annotations.QueryProjection
data class GetAdminContentCurationResponse @QueryProjection constructor(
val id: Long,
val title: String,
val description: String,
val isAdult: Boolean
)

View File

@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.admin.content.theme
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/audio-content/theme")
@PreAuthorize("hasRole('ADMIN')")
class AdminContentThemeController(private val service: AdminContentThemeService) {
@PostMapping
fun enrollmentTheme(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.uploadThemeImage(image, requestString), "등록되었습니다.")
@DeleteMapping("/{id}")
fun deleteTheme(@PathVariable id: Long) = ApiResponse.ok(service.deleteTheme(id), "삭제되었습니다.")
@PutMapping("/orders")
fun updateTagOrders(
@RequestBody request: UpdateThemeOrdersRequest
) = ApiResponse.ok(service.updateTagOrders(request.ids), "수정되었습니다.")
@GetMapping
fun getThemes() = ApiResponse.ok(service.getThemes())
}

View File

@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.admin.content.theme
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.theme.QGetAudioContentThemeResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminContentThemeRepository : JpaRepository<AudioContentTheme, Long>, AdminContentThemeQueryRepository
interface AdminContentThemeQueryRepository {
fun findIdByTheme(theme: String): Long?
fun getActiveThemes(): List<GetAudioContentThemeResponse>
}
class AdminContentThemeQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : AdminContentThemeQueryRepository {
override fun findIdByTheme(theme: String): Long? {
return queryFactory
.select(audioContentTheme.id)
.from(audioContentTheme)
.where(audioContentTheme.theme.eq(theme))
.fetchOne()
}
override fun getActiveThemes(): List<GetAudioContentThemeResponse> {
return queryFactory
.select(
QGetAudioContentThemeResponse(
audioContentTheme.id,
audioContentTheme.theme,
audioContentTheme.image.prepend("/").prepend(cloudFrontHost)
)
)
.from(audioContentTheme)
.where(audioContentTheme.isActive.isTrue)
.orderBy(audioContentTheme.orders.asc())
.fetch()
}
}

View File

@ -0,0 +1,70 @@
package kr.co.vividnext.sodalive.admin.content.theme
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminContentThemeService(
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
private val repository: AdminContentThemeRepository,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@Transactional
fun uploadThemeImage(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateContentThemeRequest::class.java)
themeExistCheck(request)
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audio_content_theme/$fileName"
)
return createTheme(request.theme, imagePath)
}
fun createTheme(theme: String, imagePath: String) {
repository.save(AudioContentTheme(theme = theme, image = imagePath))
}
fun themeExistCheck(request: CreateContentThemeRequest) {
repository.findIdByTheme(request.theme)?.let { throw SodaException("이미 등록된 테마 입니다.") }
}
@Transactional
fun deleteTheme(id: Long) {
val theme = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 요청입니다.")
theme.theme = "${theme.theme}_deleted"
theme.isActive = false
}
@Transactional
fun updateTagOrders(ids: List<Long>) {
for (index in ids.indices) {
val theme = repository.findByIdOrNull(ids[index])
if (theme != null) {
theme.orders = index + 1
}
}
}
fun getThemes(): List<GetAudioContentThemeResponse> {
return repository.getActiveThemes()
}
}

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.admin.content.theme
data class CreateContentThemeRequest(val theme: String)

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.admin.content.theme
data class UpdateThemeOrdersRequest(
val ids: List<Long>
)

View File

@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.admin.event
import kr.co.vividnext.sodalive.common.ApiResponse
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
@RequestMapping("/event/charge")
class AdminChargeEventController(private val service: AdminChargeEventService) {
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
fun createChargeEvent(@RequestBody request: CreateChargeEventRequest): ApiResponse<Any> {
service.createChargeEvent(request)
return ApiResponse.ok(null, "등록되었습니다.")
}
@PutMapping
@PreAuthorize("hasRole('ADMIN')")
fun modifyChargeEvent(@RequestBody request: ModifyChargeEventRequest) = ApiResponse.ok(
service.modifyChargeEvent(request),
"수정되었습니다."
)
@GetMapping("/list")
@PreAuthorize("hasRole('ADMIN')")
fun getChargeEventList() = ApiResponse.ok(service.getChargeEventList())
}

View File

@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.admin.event
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.event.QChargeEvent.chargeEvent
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminChargeEventRepository : JpaRepository<ChargeEvent, Long>, AdminChargeEventQueryRepository
interface AdminChargeEventQueryRepository {
fun getChargeEventList(): List<ChargeEvent>
}
class AdminChargeEventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminChargeEventQueryRepository {
override fun getChargeEventList(): List<ChargeEvent> {
return queryFactory
.selectFrom(chargeEvent)
.orderBy(chargeEvent.createdAt.desc())
.fetch()
}
}

View File

@ -0,0 +1,100 @@
package kr.co.vividnext.sodalive.admin.event
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.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
@Transactional(readOnly = true)
class AdminChargeEventService(private val repository: AdminChargeEventRepository) {
@Transactional
fun createChargeEvent(request: CreateChargeEventRequest): Long {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = LocalDate.parse(request.startDateString, dateTimeFormatter).atTime(0, 0)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val endDate = LocalDate.parse(request.endDateString, dateTimeFormatter).atTime(23, 59, 59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val chargeEvent = ChargeEvent(
title = request.title,
startDate = startDate,
endDate = endDate,
availableCount = request.availableCount,
addPercent = request.addPercent / 100f
)
return repository.save(chargeEvent).id!!
}
@Transactional
fun modifyChargeEvent(request: ModifyChargeEventRequest) {
val chargeEvent = repository.findByIdOrNull(request.id)
?: throw SodaException("해당하는 충전이벤트가 없습니다\n다시 시도해 주세요.")
if (request.title != null) {
chargeEvent.title = request.title
}
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
if (request.startDateString != null) {
chargeEvent.startDate = LocalDate.parse(request.startDateString, dateTimeFormatter).atTime(0, 0)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
}
if (request.endDateString != null) {
chargeEvent.endDate = LocalDate.parse(request.endDateString, dateTimeFormatter).atTime(23, 59, 59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
}
if (request.availableCount != null) {
chargeEvent.availableCount = request.availableCount
}
if (request.addPercent != null) {
chargeEvent.addPercent = request.addPercent / 100f
}
if (request.isActive != null) {
chargeEvent.isActive = request.isActive
}
}
fun getChargeEventList(): List<GetChargeEventListResponse> {
return repository.getChargeEventList()
.map {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = it.startDate
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.format(dateTimeFormatter)
val endDate = it.endDate
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.format(dateTimeFormatter)
GetChargeEventListResponse(
id = it.id!!,
title = it.title,
startDate = startDate,
endDate = endDate,
availableCount = it.availableCount,
addPercent = (it.addPercent * 100).toInt(),
isActive = it.isActive
)
}
}
}

View File

@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.admin.event
import kr.co.vividnext.sodalive.common.BaseEntity
import java.time.LocalDateTime
import javax.persistence.Entity
@Entity
data class ChargeEvent(
var title: String,
var startDate: LocalDateTime,
var endDate: LocalDateTime,
var availableCount: Int,
var addPercent: Float,
var isActive: Boolean = true
) : BaseEntity()

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.event
data class CreateChargeEventRequest(
val title: String,
val startDateString: String,
val endDateString: String,
val availableCount: Int,
val addPercent: Int
)

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.admin.event
data class GetChargeEventListResponse(
val id: Long,
val title: String,
val startDate: String,
val endDate: String,
val availableCount: Int,
val addPercent: Int,
val isActive: Boolean
)

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.admin.event
data class ModifyChargeEventRequest(
val id: Long,
val title: String? = null,
val startDateString: String? = null,
val endDateString: String? = null,
val availableCount: Int? = null,
val addPercent: Int? = null,
val isActive: Boolean? = null
)

View File

@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.admin.explorer
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
@RequestMapping("/admin/explorer")
@PreAuthorize("hasRole('ADMIN')")
class AdminExplorerController(private val service: AdminExplorerService) {
@PostMapping
fun createExplorerSection(@RequestBody request: CreateExplorerSectionRequest): ApiResponse<Any> {
service.createExplorerSection(request)
return ApiResponse.ok(null, "등록되었습니다.")
}
@PutMapping
fun updateExplorerSection(@RequestBody request: UpdateExplorerSectionRequest) = ApiResponse.ok(
service.updateExplorerSection(request),
"수정되었습니다."
)
@PutMapping("/orders")
fun updateExplorerSectionOrders(
@RequestBody request: UpdateExplorerSectionOrdersRequest
) = ApiResponse.ok(
service.updateExplorerSectionOrders(request.firstOrders, request.ids),
"수정되었습니다."
)
@GetMapping
fun getExplorerSections(pageable: Pageable) = ApiResponse.ok(service.getExplorerSections(pageable))
}

View File

@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.admin.explorer
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.explorer.section.ExplorerSection
import kr.co.vividnext.sodalive.explorer.section.QExplorerSection.explorerSection
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminExplorerSectionRepository : JpaRepository<ExplorerSection, Long>, AdminExplorerSectionQueryRepository
interface AdminExplorerSectionQueryRepository {
fun findByTitle(title: String): ExplorerSection?
fun findAllWithPaging(offset: Long, limit: Long): List<ExplorerSection>
fun totalCount(): Int
}
class AdminExplorerSectionQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AdminExplorerSectionQueryRepository {
override fun findByTitle(title: String): ExplorerSection? {
return queryFactory
.selectFrom(explorerSection)
.where(explorerSection.title.eq(title))
.fetchFirst()
}
override fun findAllWithPaging(offset: Long, limit: Long): List<ExplorerSection> {
return queryFactory
.selectFrom(explorerSection)
.offset(offset)
.limit(limit)
.orderBy(explorerSection.orders.asc())
.fetch()
}
override fun totalCount(): Int {
return queryFactory
.selectFrom(explorerSection)
.fetch()
.size
}
}

View File

@ -0,0 +1,144 @@
package kr.co.vividnext.sodalive.admin.explorer
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.explorer.section.ExplorerSection
import kr.co.vividnext.sodalive.explorer.section.ExplorerSectionCreatorTag
import kr.co.vividnext.sodalive.member.tag.MemberTagRepository
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class AdminExplorerService(
private val repository: AdminExplorerSectionRepository,
private val memberTagRepository: MemberTagRepository
) {
@Transactional
fun createExplorerSection(request: CreateExplorerSectionRequest): Long {
if (request.title.isBlank()) throw SodaException("제목을 입력하세요.")
val findExplorerSection = repository.findByTitle(request.title)
if (findExplorerSection != null) throw SodaException("동일한 제목이 있습니다.")
val explorerSection = ExplorerSection(title = request.title, isAdult = request.isAdult)
explorerSection.coloredTitle = request.coloredTitle
explorerSection.color = request.color
val tags = mutableListOf<ExplorerSectionCreatorTag>()
request.tagList.forEach {
val findTag = memberTagRepository.findByTag(it)
if (findTag != null) {
val tag = ExplorerSectionCreatorTag()
tag.explorerSection = explorerSection
tag.tag = findTag
tags.add(tag)
}
}
if (tags.size <= 0) throw SodaException("관심사를 선택하세요.")
explorerSection.tags = tags
return repository.save(explorerSection).id!!
}
@Transactional
fun updateExplorerSection(request: UpdateExplorerSectionRequest) {
if (
request.title == null &&
request.isAdult == null &&
request.tagList == null &&
request.color == null &&
request.coloredTitle == null &&
request.isActive == null
) {
throw SodaException("변경사항이 없습니다.")
}
val explorerSection = repository.findByIdOrNull(request.id)
?: throw SodaException("해당하는 섹션이 없습니다.")
if (request.title != null) {
if (request.title.isBlank()) throw SodaException("올바른 제목을 입력하세요.")
explorerSection.title = request.title
}
if (request.isActive != null) {
explorerSection.isActive = request.isActive
}
if (request.isAdult != null) {
explorerSection.isAdult = request.isAdult
}
if (request.color != null) {
explorerSection.color = request.color
}
if (request.coloredTitle != null) {
explorerSection.coloredTitle = request.coloredTitle
}
if (request.tagList != null) {
val requestTagList = request.tagList.toMutableList()
val tags = explorerSection.tags.filter {
requestTagList.contains(it.tag!!.tag)
requestTagList.remove(it.tag!!.tag)
}.toMutableList()
requestTagList.forEach {
val findTag = memberTagRepository.findByTag(it)
if (findTag != null) {
val tag = ExplorerSectionCreatorTag()
tag.explorerSection = explorerSection
tag.tag = findTag
tags.add(tag)
}
}
if (tags.size <= 0) throw SodaException("관심사를 입력하세요.")
if (tags != explorerSection.tags) {
explorerSection.tags.clear()
explorerSection.tags.addAll(tags)
}
}
}
@Transactional
fun updateExplorerSectionOrders(firstOrders: Int, ids: List<Long>) {
for (index in ids.indices) {
val explorerSection = repository.findByIdOrNull(ids[index])
if (explorerSection != null) {
explorerSection.orders = firstOrders + index
}
}
}
fun getExplorerSections(pageable: Pageable): GetAdminExplorerSectionResponse {
val totalCount = repository.totalCount()
val explorerSectionItemList = repository
.findAllWithPaging(pageable.offset, pageable.pageSize.toLong())
.map {
GetAdminExplorerSectionResponseItem(
id = it.id!!,
title = it.title,
coloredTitle = it.coloredTitle ?: "",
color = it.color ?: "",
isAdult = it.isAdult,
isActive = it.isActive,
tags = it.tags
.asSequence()
.filter { explorerSectionTag -> explorerSectionTag.tag!!.isActive }
.map { explorerSectionTag -> explorerSectionTag.tag!!.tag }
.toList()
)
}
return GetAdminExplorerSectionResponse(
totalCount = totalCount,
explorerSectionItemList = explorerSectionItemList
)
}
}

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.explorer
data class CreateExplorerSectionRequest(
val title: String,
val isAdult: Boolean,
val tagList: List<String>,
val coloredTitle: String? = null,
val color: String? = null
)

View File

@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.admin.explorer
data class GetAdminExplorerSectionResponse(
val totalCount: Int,
val explorerSectionItemList: List<GetAdminExplorerSectionResponseItem>
)
data class GetAdminExplorerSectionResponseItem(
val id: Long,
val title: String,
val coloredTitle: String,
val color: String,
val isAdult: Boolean,
val isActive: Boolean,
val tags: List<String>
)

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.explorer
data class UpdateExplorerSectionOrdersRequest(
val firstOrders: Int,
val ids: List<Long>
)

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.admin.explorer
data class UpdateExplorerSectionRequest(
val id: Long,
val title: String? = null,
val isAdult: Boolean? = null,
val tagList: List<String>? = null,
val coloredTitle: String? = null,
val color: String? = null,
val isActive: Boolean? = null
)

View File

@ -0,0 +1,58 @@
package kr.co.vividnext.sodalive.admin.live
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.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/live")
class AdminLiveController(private val service: AdminLiveService) {
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
fun getOnAirLive() = ApiResponse.ok(data = service.getLiveList())
@GetMapping("/recommend-creator")
@PreAuthorize("hasRole('ADMIN')")
fun getRecommendCreatorBanner(pageable: Pageable) = ApiResponse.ok(service.getRecommendCreator(pageable))
@PostMapping("/recommend-creator")
fun createRecommendCreatorBanner(
@RequestParam("image") image: MultipartFile,
@RequestParam("creator_id") creatorId: Long,
@RequestParam("start_date") startDate: String,
@RequestParam("end_date") endDate: String,
@RequestParam("is_adult") isAdult: Boolean
) = ApiResponse.ok(
service.createRecommendCreatorBanner(image, creatorId, startDate, endDate, isAdult),
"등록되었습니다."
)
@PutMapping("/recommend-creator")
fun updateRecommendCreatorBanner(
@RequestParam("recommend_creator_banner_id") recommendCreatorBannerId: Long,
@RequestParam("image", required = false) image: MultipartFile?,
@RequestParam("creator_id", required = false) creatorId: Long?,
@RequestParam("start_date", required = false) startDate: String?,
@RequestParam("end_date", required = false) endDate: String?,
@RequestParam("is_adult", required = false) isAdult: Boolean?
) = ApiResponse.ok(
service.updateRecommendCreatorBanner(recommendCreatorBannerId, image, creatorId, startDate, endDate, isAdult),
"수정되었습니다."
)
@PutMapping("/recommend-creator/orders")
fun updateRecommendCreatorBannerOrders(
@RequestBody request: UpdateAdminRecommendCreatorBannerOrdersRequest
) = ApiResponse.ok(
service.updateRecommendCreatorBannerOrders(request.firstOrders, request.ids),
"수정되었습니다."
)
}

View File

@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.admin.live
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.live.recommend.QRecommendLiveCreatorBanner.recommendLiveCreatorBanner
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Repository
@Repository
class AdminLiveRoomQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getLiveRoomList(): List<LiveRoom> {
return queryFactory
.selectFrom(liveRoom)
.innerJoin(liveRoom.member, member)
.where(liveRoom.isActive.isTrue)
.orderBy(liveRoom.channelName.desc(), liveRoom.beginDateTime.asc())
.fetch()
}
fun getRecommendCreatorTotalCount(): Int {
return queryFactory
.select(recommendLiveCreatorBanner.id)
.from(recommendLiveCreatorBanner)
.fetch()
.size
}
fun getRecommendCreatorList(pageable: Pageable): List<RecommendLiveCreatorBanner> {
return queryFactory
.selectFrom(recommendLiveCreatorBanner)
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(recommendLiveCreatorBanner.orders.asc(), recommendLiveCreatorBanner.id.desc())
.fetch()
}
}

View File

@ -0,0 +1,240 @@
package kr.co.vividnext.sodalive.admin.live
import com.amazonaws.services.s3.model.ObjectMetadata
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBannerRepository
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class AdminLiveService(
private val recommendCreatorBannerRepository: RecommendLiveCreatorBannerRepository,
private val repository: AdminLiveRoomQueryRepository,
private val memberRepository: MemberRepository,
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String
) {
fun getLiveList(): GetLiveResponse {
return GetLiveResponse(
liveList = repository.getLiveRoomList()
.asSequence()
.map {
GetLiveResponseItem(
id = it.id!!,
title = it.title,
content = it.notice,
managerNickname = it.member!!.nickname,
coverImageUrl = if (it.coverImage!!.startsWith("https://")) {
it.coverImage!!
} else {
"$coverImageHost/${it.coverImage!!}"
},
channelName = it.channelName ?: "",
type = it.type,
password = it.password,
isAdult = it.isAdult
)
}
.toList()
)
}
fun getRecommendCreator(pageable: Pageable): GetAdminRecommendCreatorResponse {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val totalCount = repository.getRecommendCreatorTotalCount()
val recommendCreatorList = repository
.getRecommendCreatorList(pageable)
.asSequence()
.map {
GetAdminRecommendCreatorResponseItem(
it.id!!,
"$coverImageHost/${it.image}",
it.creator!!.id!!,
it.creator!!.nickname,
it.startDate
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime()
.format(dateTimeFormatter),
it.endDate
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime()
.format(dateTimeFormatter),
it.isAdult
)
}
.toList()
return GetAdminRecommendCreatorResponse(
totalCount = totalCount,
recommendCreatorList = recommendCreatorList
)
}
@Transactional
fun createRecommendCreatorBanner(
image: MultipartFile,
creatorId: Long,
startDateString: String,
endDateString: String,
isAdult: Boolean
): Long {
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val nowDate = LocalDateTime.now()
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
if (startDate < nowDate) throw SodaException("노출 시작일은 현재시간 이후로 설정하셔야 합니다.")
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
if (endDate < nowDate) throw SodaException("노출 종료일은 현재시간 이후로 설정하셔야 합니다.")
if (endDate <= startDate) throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
val recommendCreatorBanner = RecommendLiveCreatorBanner(
startDate = startDate,
endDate = endDate,
isAdult = isAdult
)
recommendCreatorBanner.creator = creator
recommendCreatorBannerRepository.save(recommendCreatorBanner)
val metadata = ObjectMetadata()
metadata.contentLength = image.size
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "recommend_creator_banner/${recommendCreatorBanner.id}/${generateFileName()}",
metadata = metadata
)
recommendCreatorBanner.image = imagePath
return recommendCreatorBanner.id!!
}
@Transactional
fun updateRecommendCreatorBanner(
recommendCreatorBannerId: Long,
image: MultipartFile?,
creatorId: Long?,
startDateString: String?,
endDateString: String?,
isAdult: Boolean?
) {
val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(recommendCreatorBannerId)
?: throw SodaException("해당하는 추천라이브가 없습니다. 다시 확인해 주세요.")
if (creatorId != null) {
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
recommendCreatorBanner.creator = creator
}
if (image != null) {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "recommend_creator_banner/${recommendCreatorBanner.id}/${generateFileName()}",
metadata = metadata
)
recommendCreatorBanner.image = imagePath
}
if (startDateString != null) {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val endDate = if (endDateString != null) {
LocalDateTime.parse(endDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
} else {
null
}
if (endDate != null) {
if (endDate <= startDate) {
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
}
recommendCreatorBanner.endDate = endDate
} else {
if (recommendCreatorBanner.endDate <= startDate) {
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
}
}
recommendCreatorBanner.startDate = startDate
} else if (endDateString != null) {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
if (endDate <= recommendCreatorBanner.startDate) {
throw SodaException("노출 종료일은 노출 시작일 이후로 설정하셔야 합니다.")
}
recommendCreatorBanner.endDate = endDate
}
if (isAdult != null) {
recommendCreatorBanner.isAdult = isAdult
}
}
@Transactional
fun updateRecommendCreatorBannerOrders(firstOrders: Int, ids: List<Long>) {
for (index in ids.indices) {
val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(id = ids[index])
if (recommendCreatorBanner != null) {
recommendCreatorBanner.orders = firstOrders + index
}
}
}
}

View File

@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.admin.live
data class GetAdminRecommendCreatorResponse(
val totalCount: Int,
val recommendCreatorList: List<GetAdminRecommendCreatorResponseItem>
)
data class GetAdminRecommendCreatorResponseItem(
val id: Long,
val image: String,
val creatorId: Long,
val creatorNickname: String,
val startDate: String,
val endDate: String,
val isAdult: Boolean
)

View File

@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.live
import kr.co.vividnext.sodalive.live.room.LiveRoomType
data class GetLiveResponse(
val liveList: List<GetLiveResponseItem>
)
data class GetLiveResponseItem(
val id: Long,
val title: String,
val content: String,
val managerNickname: String,
val coverImageUrl: String,
val channelName: String,
val type: LiveRoomType,
val password: String?,
val isAdult: Boolean
)

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.live
data class UpdateAdminRecommendCreatorBannerOrdersRequest(
val firstOrders: Int,
val ids: List<Long>
)

View File

@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.admin.member
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.PutMapping
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
@RestController
@RequestMapping("/admin/member")
@PreAuthorize("hasRole('ADMIN')")
class AdminMemberController(private val service: AdminMemberService) {
@PutMapping
fun updateMember(@RequestBody request: UpdateMemberRequest) = ApiResponse.ok(
service.updateMember(request = request),
"수정되었습니다."
)
@GetMapping("/list")
fun getMemberList(pageable: Pageable) = ApiResponse.ok(service.getMemberList(pageable))
@GetMapping("/search")
fun searchMember(
@RequestParam(value = "search_word") searchWord: String,
pageable: Pageable
) = ApiResponse.ok(service.searchMember(searchWord, pageable))
@GetMapping("/creator/all/list")
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
@GetMapping("/creator/list")
fun getCreatorList(pageable: Pageable) = ApiResponse.ok(service.getCreatorList(pageable))
@GetMapping("/creator/search")
fun searchCreator(
@RequestParam(value = "search_word") searchWord: String,
pageable: Pageable
) = ApiResponse.ok(service.searchCreator(searchWord, pageable))
}

View File

@ -0,0 +1,112 @@
package kr.co.vividnext.sodalive.admin.member
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
interface AdminMemberRepository : JpaRepository<Member, Long>, AdminMemberQueryRepository
interface AdminMemberQueryRepository {
fun getMemberTotalCount(role: MemberRole? = null): Int
fun getMemberList(offset: Long, limit: Long, role: MemberRole? = null): List<Member>
fun searchMember(searchWord: String, offset: Long, limit: Long, role: MemberRole? = null): List<Member>
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
}
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
override fun getMemberList(offset: Long, limit: Long, role: MemberRole?): List<Member> {
return queryFactory
.selectFrom(member)
.where(
member.role.ne(MemberRole.ADMIN)
.and(
if (role != null) {
member.role.eq(role)
} else {
null
}
)
)
.offset(offset)
.limit(limit)
.orderBy(member.id.desc())
.fetch()
}
override fun getMemberTotalCount(role: MemberRole?): Int {
return queryFactory
.select(member.id)
.from(member)
.where(
member.role.ne(MemberRole.ADMIN)
.and(
if (role != null) {
member.role.eq(role)
} else {
null
}
)
)
.fetch()
.size
}
override fun searchMember(searchWord: String, offset: Long, limit: Long, role: MemberRole?): List<Member> {
return queryFactory
.selectFrom(member)
.where(
member.nickname.contains(searchWord)
.or(member.email.contains(searchWord))
.and(
if (role != null) {
member.role.eq(role)
} else {
null
}
)
)
.offset(offset)
.limit(limit)
.orderBy(member.id.desc())
.fetch()
}
override fun searchMemberTotalCount(searchWord: String, role: MemberRole?): Int {
return queryFactory
.select(member.id)
.from(member)
.where(
member.nickname.contains(searchWord)
.or(member.email.contains(searchWord))
.and(
if (role != null) {
member.role.eq(role)
} else {
null
}
)
)
.fetch()
.size
}
override fun getCreatorAllList(): List<GetAdminCreatorAllListResponse> {
return queryFactory
.select(
QGetAdminCreatorAllListResponse(
member.id,
member.nickname
)
)
.from(member)
.where(
member.role.eq(MemberRole.CREATOR)
.and(member.isActive.isTrue)
)
.fetch()
}
}

View File

@ -0,0 +1,136 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class AdminMemberService(
private val repository: AdminMemberRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
@Transactional
fun updateMember(request: UpdateMemberRequest) {
val member = repository.findByIdOrNull(request.id)
?: throw SodaException("해당 유저가 없습니다.")
if (member.role != request.userType) {
member.role = request.userType
}
}
fun getMemberList(pageable: Pageable): GetAdminMemberListResponse {
val totalCount = repository.getMemberTotalCount()
val memberList = processMemberListToGetAdminMemberListResponseItemList(
memberList = repository.getMemberList(
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
return GetAdminMemberListResponse(totalCount, memberList)
}
fun searchMember(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord)
val memberList = processMemberListToGetAdminMemberListResponseItemList(
memberList = repository.searchMember(
searchWord = searchWord,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
return GetAdminMemberListResponse(totalCount, memberList)
}
fun getCreatorList(pageable: Pageable): GetAdminMemberListResponse {
val totalCount = repository.getMemberTotalCount(role = MemberRole.CREATOR)
val creatorList = processMemberListToGetAdminMemberListResponseItemList(
memberList = repository.getMemberList(
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
role = MemberRole.CREATOR
)
)
return GetAdminMemberListResponse(totalCount, creatorList)
}
fun searchCreator(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord, role = MemberRole.CREATOR)
val creatorList = processMemberListToGetAdminMemberListResponseItemList(
memberList = repository.searchMember(
searchWord = searchWord,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
role = MemberRole.CREATOR
)
)
return GetAdminMemberListResponse(totalCount, creatorList)
}
private fun processMemberListToGetAdminMemberListResponseItemList(
memberList: List<Member>
): List<GetAdminMemberListResponseItem> {
return memberList
.asSequence()
.map {
val userType = when (it.role) {
MemberRole.ADMIN -> "관리자"
MemberRole.USER -> "일반회원"
MemberRole.CREATOR -> "크리에이터"
MemberRole.AGENT -> "에이전트"
MemberRole.BOT -> ""
}
val signUpDate = it.createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
val signOutDate = if (it.signOutReasons.isNotEmpty()) {
it.signOutReasons.last().createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
} else {
""
}
GetAdminMemberListResponseItem(
id = it.id!!,
email = it.email,
nickname = it.nickname,
profileUrl = if (it.profileImage != null) {
"$cloudFrontHost/${it.profileImage}"
} else {
"$cloudFrontHost/profile/default-profile.png"
},
userType = userType,
container = it.container,
auth = it.auth != null,
signUpDate = signUpDate,
signOutDate = signOutDate,
isActive = it.isActive
)
}
.toList()
}
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse> {
return repository.getCreatorAllList()
}
}

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.admin.member
import com.querydsl.core.annotations.QueryProjection
data class GetAdminCreatorAllListResponse @QueryProjection constructor(
val id: Long,
val nickname: String
)

View File

@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.member
data class GetAdminMemberListResponse(
val totalCount: Int,
val items: List<GetAdminMemberListResponseItem>
)
data class GetAdminMemberListResponseItem(
val id: Long,
val email: String,
val nickname: String,
val profileUrl: String,
val userType: String,
val container: String,
val auth: Boolean,
val signUpDate: String,
val signOutDate: String,
val isActive: Boolean
)

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.member.MemberRole
data class UpdateMemberRequest(
val id: Long,
val userType: MemberRole
)

View File

@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.admin.member.tag
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.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.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/member/tag")
@PreAuthorize("hasRole('ADMIN')")
class AdminMemberTagController(private val service: AdminMemberTagService) {
@PostMapping
fun enrollmentCreatorTag(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.uploadTagImage(image, requestString), "등록되었습니다.")
@DeleteMapping("/{id}")
fun deleteCreatorTag(@PathVariable id: Long) = ApiResponse.ok(service.deleteTag(id), "삭제되었습니다.")
@PutMapping("/{id}")
fun modifyCreatorTag(
@PathVariable id: Long,
@RequestPart("image") image: MultipartFile?,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.modifyTag(id, image, requestString), "수정되었습니다.")
@PutMapping("/orders")
fun updateTagOrders(
@RequestBody request: UpdateTagOrdersRequest
) = ApiResponse.ok(service.updateTagOrders(request.ids), "수정되었습니다.")
}

View File

@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.admin.member.tag
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.member.tag.CreatorTag
import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag
import org.springframework.data.jpa.repository.JpaRepository
interface AdminMemberTagRepository : JpaRepository<CreatorTag, Long>, AdminMemberTagQueryRepository
interface AdminMemberTagQueryRepository {
fun findByTag(tag: String): Long?
}
class AdminMemberTagQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberTagQueryRepository {
override fun findByTag(tag: String): Long? {
return queryFactory
.select(creatorTag.id)
.from(creatorTag)
.where(creatorTag.tag.eq(tag))
.fetchFirst()
}
}

View File

@ -0,0 +1,83 @@
package kr.co.vividnext.sodalive.admin.member.tag
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.tag.CreatorTag
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminMemberTagService(
private val repository: AdminMemberTagRepository,
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@Transactional
fun uploadTagImage(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java)
tagExistCheck(request)
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "creator_tag/$fileName"
)
return createTag(request.tag, imagePath)
}
private fun tagExistCheck(request: CreateMemberTagRequest) {
repository.findByTag(request.tag)?.let { throw SodaException("이미 등록된 태그 입니다.") }
}
private fun createTag(tag: String, imagePath: String) {
repository.save(CreatorTag(tag, imagePath))
}
@Transactional
fun deleteTag(id: Long) {
val creatorTag = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 요청입니다.")
creatorTag.tag = "${creatorTag.tag}_deleted"
creatorTag.isActive = false
}
@Transactional
fun modifyTag(id: Long, image: MultipartFile?, requestString: String) {
val creatorTag = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 요청입니다.")
val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java)
creatorTag.tag = request.tag
if (image != null) {
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "creator_tag/$fileName"
)
creatorTag.image = imagePath
}
}
@Transactional
fun updateTagOrders(ids: List<Long>) {
for (index in ids.indices) {
val tag = repository.findByIdOrNull(ids[index])
if (tag != null) {
tag.orders = index + 1
}
}
}
}

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.admin.member.tag
data class CreateMemberTagRequest(val tag: String)

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.admin.member.tag
data class UpdateTagOrdersRequest(val ids: List<Long>)

View File

@ -0,0 +1,171 @@
package kr.co.vividnext.sodalive.agora
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.TreeMap
class AccessToken(
var appId: String,
private val appCertificate: String,
val channelName: String,
private val uid: String,
var crcChannelName: Int = 0,
private var crcUid: Int = 0,
val message: PrivilegeMessage = PrivilegeMessage()
) {
private lateinit var signature: ByteArray
private lateinit var messageRawContent: ByteArray
enum class Privileges(value: Int) {
JoinChannel(1),
PublishAudioStream(2),
PublishVideoStream(3),
PublishDataStream(4), // For RTM only
RtmLogin(1000);
var intValue: Short
init {
intValue = value.toShort()
}
}
@Throws(Exception::class)
fun build(): String {
if (!AgoraUtils.isUUID(appId)) {
return ""
}
if (!AgoraUtils.isUUID(appCertificate)) {
return ""
}
messageRawContent = AgoraUtils.pack(message)
signature = generateSignature(
appCertificate,
appId,
channelName,
uid,
messageRawContent
)
crcChannelName = AgoraUtils.crc32(channelName)
crcUid = AgoraUtils.crc32(uid)
val packContent = PackContent(signature, crcChannelName, crcUid, messageRawContent)
val content: ByteArray = AgoraUtils.pack(packContent)
return getVersion() + appId + AgoraUtils.base64Encode(content)
}
fun addPrivilege(privilege: Privileges, expireTimestamp: Int) {
message.messages[privilege.intValue] = expireTimestamp
}
private fun getVersion(): String {
return VER
}
@Throws(java.lang.Exception::class)
fun generateSignature(
appCertificate: String,
appID: String,
channelName: String,
uid: String,
message: ByteArray
): ByteArray {
val baos = ByteArrayOutputStream()
try {
baos.write(appID.toByteArray())
baos.write(channelName.toByteArray())
baos.write(uid.toByteArray())
baos.write(message)
} catch (e: IOException) {
e.printStackTrace()
}
return AgoraUtils.hmacSign(appCertificate, baos.toByteArray())
}
fun fromString(token: String): Boolean {
if (getVersion() != token.substring(0, AgoraUtils.VERSION_LENGTH)) {
return false
}
try {
appId = token.substring(AgoraUtils.VERSION_LENGTH, AgoraUtils.VERSION_LENGTH + AgoraUtils.APP_ID_LENGTH)
val packContent = PackContent()
AgoraUtils.unpack(
AgoraUtils.base64Decode(
token.substring(
AgoraUtils.VERSION_LENGTH + AgoraUtils.APP_ID_LENGTH,
token.length
)
),
packContent
)
signature = packContent.signature
crcChannelName = packContent.crcChannelName
crcUid = packContent.crcUid
messageRawContent = packContent.rawMessage
AgoraUtils.unpack(messageRawContent, message)
} catch (e: java.lang.Exception) {
e.printStackTrace()
return false
}
return true
}
class PrivilegeMessage : PackableEx {
var salt: Int
var ts: Int
var messages: TreeMap<Short, Int>
override fun marshal(out: ByteBuf): ByteBuf {
return out.put(salt).put(ts).putIntMap(messages)
}
override fun unmarshal(input: ByteBuf) {
salt = input.readInt()
ts = input.readInt()
messages = input.readIntMap()
}
init {
salt = AgoraUtils.randomInt()
ts = AgoraUtils.getTimestamp() + 24 * 3600
messages = TreeMap()
}
}
class PackContent() : PackableEx {
var signature: ByteArray = byteArrayOf()
var crcChannelName = 0
var crcUid = 0
var rawMessage: ByteArray = byteArrayOf()
constructor(signature: ByteArray, crcChannelName: Int, crcUid: Int, rawMessage: ByteArray) : this() {
this.signature = signature
this.crcChannelName = crcChannelName
this.crcUid = crcUid
this.rawMessage = rawMessage
}
override fun marshal(out: ByteBuf): ByteBuf {
return out
.put(signature)
.put(crcChannelName)
.put(crcUid).put(rawMessage)
}
override fun unmarshal(input: ByteBuf) {
signature = input.readBytes()
crcChannelName = input.readInt()
crcUid = input.readInt()
rawMessage = input.readBytes()
}
}
companion object {
const val VER = "006"
}
}

View File

@ -0,0 +1,72 @@
package kr.co.vividnext.sodalive.agora
import org.apache.commons.codec.binary.Base64
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
import java.util.Date
import java.util.zip.CRC32
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object AgoraUtils {
const val HMAC_SHA256_LENGTH: Long = 32
const val VERSION_LENGTH = 3
const val APP_ID_LENGTH = 32
@Throws(InvalidKeyException::class, NoSuchAlgorithmException::class)
fun hmacSign(keyString: String, msg: ByteArray?): ByteArray {
val keySpec = SecretKeySpec(keyString.toByteArray(), "HmacSHA256")
val mac = Mac.getInstance("HmacSHA256")
mac.init(keySpec)
return mac.doFinal(msg)
}
fun pack(packableEx: PackableEx): ByteArray {
val buffer = ByteBuf()
packableEx.marshal(buffer)
return buffer.asBytes()
}
fun unpack(data: ByteArray?, packableEx: PackableEx) {
val buffer = ByteBuf(data!!)
packableEx.unmarshal(buffer)
}
fun base64Encode(data: ByteArray?): String {
val encodedBytes: ByteArray = Base64.encodeBase64(data)
return String(encodedBytes)
}
fun base64Decode(data: String): ByteArray {
return Base64.decodeBase64(data.toByteArray())
}
fun crc32(data: String): Int {
// get bytes from string
val bytes = data.toByteArray()
return crc32(bytes)
}
fun crc32(bytes: ByteArray?): Int {
val checksum = CRC32()
checksum.update(bytes)
return checksum.value.toInt()
}
fun getTimestamp(): Int {
return (Date().time / 1000).toInt()
}
fun randomInt(): Int {
return SecureRandom().nextInt()
}
fun isUUID(uuid: String): Boolean {
return if (uuid.length != 32) {
false
} else {
uuid.matches("\\p{XDigit}+".toRegex())
}
}
}

View File

@ -0,0 +1,111 @@
package kr.co.vividnext.sodalive.agora
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.TreeMap
class ByteBuf() {
private var buffer = ByteBuffer.allocate(1024).order(ByteOrder.LITTLE_ENDIAN)
constructor(bytes: ByteArray) : this() {
this.buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
}
fun asBytes(): ByteArray {
val out = ByteArray(buffer.position())
buffer.rewind()
buffer[out, 0, out.size]
return out
}
// packUint16
fun put(v: Short): ByteBuf {
buffer.putShort(v)
return this
}
fun put(v: ByteArray): ByteBuf {
put(v.size.toShort())
buffer.put(v)
return this
}
// packUint32
fun put(v: Int): ByteBuf {
buffer.putInt(v)
return this
}
fun put(v: Long): ByteBuf {
buffer.putLong(v)
return this
}
fun put(v: String): ByteBuf {
return put(v.toByteArray())
}
fun put(extra: TreeMap<Short, String>): ByteBuf {
put(extra.size.toShort())
for ((key, value) in extra.entries) {
put(key)
put(value)
}
return this
}
fun putIntMap(extra: TreeMap<Short, Int>): ByteBuf {
put(extra.size.toShort())
for ((key, value) in extra.entries) {
put(key)
put(value)
}
return this
}
fun readShort(): Short {
return buffer.short
}
fun readInt(): Int {
return buffer.int
}
fun readBytes(): ByteArray {
val length = readShort()
val bytes = ByteArray(length.toInt())
buffer[bytes]
return bytes
}
fun readString(): String {
val bytes = readBytes()
return String(bytes)
}
fun readMap(): TreeMap<Short, String> {
val map = TreeMap<Short, String>()
val length = readShort()
for (i in 0 until length) {
val k = readShort()
val v = readString()
map[k] = v
}
return map
}
fun readIntMap(): TreeMap<Short, Int> {
val map = TreeMap<Short, Int>()
val length = readShort()
for (i in 0 until length) {
val k = readShort()
val v = readInt()
map[k] = v
}
return map
}
}

View File

@ -0,0 +1,256 @@
package kr.co.vividnext.sodalive.agora
import org.apache.commons.codec.binary.Base64
import org.apache.commons.codec.binary.Hex
import java.util.TreeMap
class DynamicKey5 {
lateinit var content: DynamicKey5Content
fun fromString(key: String): Boolean {
if (key.substring(0, 3) != version) {
return false
}
val rawContent: ByteArray = Base64().decode(key.substring(3))
if (rawContent.isEmpty()) {
return false
}
content = DynamicKey5Content()
val buffer = ByteBuf(rawContent)
content.unmarshall(buffer)
return true
}
companion object {
const val version = "005"
const val noUpload = "0"
const val audioVideoUpload = "3"
// ServiceType
const val MEDIA_CHANNEL_SERVICE: Short = 1
const val RECORDING_SERVICE: Short = 2
const val PUBLIC_SHARING_SERVICE: Short = 3
const val IN_CHANNEL_PERMISSION: Short = 4
// InChannelPermissionKey
const val ALLOW_UPLOAD_IN_CHANNEL: Short = 1
@Throws(Exception::class)
fun generateSignature(
appCertificate: String,
service: Short,
appID: String,
unixTs: Int,
salt: Int,
channelName: String,
uid: Long,
expiredTs: Int,
extra: TreeMap<Short, String>
): String {
// decode hex to avoid case problem
val hex = Hex()
val rawAppID: ByteArray = hex.decode(appID.toByteArray())
val rawAppCertificate: ByteArray = hex.decode(appCertificate.toByteArray())
val m = Message(
service,
rawAppID,
unixTs,
salt,
channelName,
(uid and 0xFFFFFFFFL).toInt(),
expiredTs,
extra
)
val toSign: ByteArray = pack(m)
return String(Hex.encodeHex(DynamicKeyUtil.encodeHMAC(rawAppCertificate, toSign), false))
}
@Throws(java.lang.Exception::class)
fun generateDynamicKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int,
extra: TreeMap<Short, String>,
service: Short
): String {
val signature = generateSignature(appCertificate, service, appID, ts, salt, channel, uid, expiredTs, extra)
val content =
DynamicKey5Content(service, signature, Hex().decode(appID.toByteArray()), ts, salt, expiredTs, extra)
val bytes: ByteArray = pack(content)
val encoded = Base64().encode(bytes)
val base64 = String(encoded)
return version + base64
}
private fun pack(content: Packable): ByteArray {
val buffer = ByteBuf()
content.marshal(buffer)
return buffer.asBytes()
}
@Throws(Exception::class)
fun generatePublicSharingKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int
) = generateDynamicKey(
appID,
appCertificate,
channel,
ts,
salt,
uid,
expiredTs,
TreeMap(),
PUBLIC_SHARING_SERVICE
)
@Throws(Exception::class)
fun generateRecordingKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int
) = generateDynamicKey(
appID,
appCertificate,
channel,
ts,
salt,
uid,
expiredTs,
TreeMap(),
RECORDING_SERVICE
)
@Throws(Exception::class)
fun generateMediaChannelKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int
) = generateDynamicKey(
appID,
appCertificate,
channel,
ts,
salt,
uid,
expiredTs,
TreeMap(),
MEDIA_CHANNEL_SERVICE
)
@Throws(Exception::class)
fun generateInChannelPermissionKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int,
permission: String
): String {
val extra = TreeMap<Short, String>()
extra[ALLOW_UPLOAD_IN_CHANNEL] = permission
return generateDynamicKey(
appID,
appCertificate,
channel,
ts,
salt,
uid,
expiredTs,
extra,
IN_CHANNEL_PERMISSION
)
}
internal class Message(
var serviceType: Short,
var appID: ByteArray,
var unixTs: Int,
var salt: Int,
var channelName: String,
var uid: Int,
var expiredTs: Int,
var extra: TreeMap<Short, String>
) : Packable {
override fun marshal(out: ByteBuf): ByteBuf {
return out
.put(serviceType)
.put(appID)
.put(unixTs)
.put(salt)
.put(channelName)
.put(uid)
.put(expiredTs)
.put(extra)
}
}
class DynamicKey5Content() : Packable {
var serviceType: Short = 0
var signature: String? = null
var appID: ByteArray = byteArrayOf()
var unixTs = 0
var salt = 0
var expiredTs = 0
var extra: TreeMap<Short, String>? = null
constructor(
serviceType: Short,
signature: String?,
appID: ByteArray,
unixTs: Int,
salt: Int,
expiredTs: Int,
extra: TreeMap<Short, String>
) : this() {
this.serviceType = serviceType
this.signature = signature
this.appID = appID
this.unixTs = unixTs
this.salt = salt
this.expiredTs = expiredTs
this.extra = extra
}
override fun marshal(out: ByteBuf): ByteBuf {
return out
.put(serviceType)
.put(signature!!)
.put(appID)
.put(unixTs)
.put(salt)
.put(expiredTs)
.put(extra!!)
}
fun unmarshall(input: ByteBuf) {
serviceType = input.readShort()
signature = input.readString()
appID = input.readBytes()
unixTs = input.readInt()
salt = input.readInt()
expiredTs = input.readInt()
extra = input.readMap()
}
}
}
}

View File

@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.agora
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Created by hefeng on 15/8/10.
* Util to generate Agora media dynamic key.
*/
object DynamicKeyUtil {
@Throws(NoSuchAlgorithmException::class, InvalidKeyException::class)
fun encodeHMAC(key: String, message: ByteArray?): ByteArray? {
return encodeHMAC(key.toByteArray(), message)
}
@Throws(NoSuchAlgorithmException::class, InvalidKeyException::class)
fun encodeHMAC(key: ByteArray?, message: ByteArray?): ByteArray? {
val keySpec = SecretKeySpec(key, "HmacSHA1")
val mac = Mac.getInstance("HmacSHA1")
mac.init(keySpec)
return mac.doFinal(message)
}
fun bytesToHex(`in`: ByteArray): String {
val builder = StringBuilder()
for (b in `in`) {
builder.append(String.format("%02x", b))
}
return builder.toString()
}
}

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.agora
interface Packable {
fun marshal(out: ByteBuf): ByteBuf
}

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.agora
interface PackableEx : Packable {
fun unmarshal(input: ByteBuf)
}

View File

@ -0,0 +1,96 @@
package kr.co.vividnext.sodalive.agora
import org.springframework.stereotype.Component
@Component
class RtcTokenBuilder {
/**
* Builds an RTC token using an int uid.
*
* @param appId The App ID issued to you by Agora.
* @param appCertificate Certificate of the application that you registered in
* the Agora Dashboard.
* @param channelName The unique channel name for the AgoraRTC session in the string format. The string length must be less than 64 bytes. Supported character scopes are:
*
* * The 26 lowercase English letters: a to z.
* * The 26 uppercase English letters: A to Z.
* * The 10 digits: 0 to 9.
* * The space.
* * "!", "#", "$", "%", "&", "(", ")", "+", "-", ":", ";", "<", "=", ".", ">", "?", "@", "[", "]", "^", "_", " {", "}", "|", "~", ",".
*
* @param uid User ID. A 32-bit unsigned integer with a value ranging from
* 1 to (2^32-1).
* @param role The user role.
*
* * Role_Publisher = 1: RECOMMENDED. Use this role for a voice/video call or a live broadcast.
* * Role_Subscriber = 2: ONLY use this role if your live-broadcast scenario requires authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in). In order for this role to take effect, please contact our support team to enable authentication for Hosting-in for you. Otherwise, Role_Subscriber still has the same privileges as Role_Publisher.
*
* @param privilegeTs Represented by the number of seconds elapsed since 1/1/1970.
* If, for example, you want to access the Agora Service within 10 minutes
* after the token is generated, set expireTimestamp as the current time stamp
* + 600 (seconds).
*/
fun buildTokenWithUid(
appId: String,
appCertificate: String,
channelName: String,
uid: Int,
privilegeTs: Int
): String {
val account = if (uid == 0) "" else uid.toString()
return buildTokenWithUserAccount(
appId,
appCertificate,
channelName,
account,
privilegeTs
)
}
/**
* Builds an RTC token using a string userAccount.
*
* @param appId The App ID issued to you by Agora.
* @param appCertificate Certificate of the application that you registered in
* the Agora Dashboard.
* @param channelName The unique channel name for the AgoraRTC session in the string format. The string length must be less than 64 bytes. Supported character scopes are:
*
* * The 26 lowercase English letters: a to z.
* * The 26 uppercase English letters: A to Z.
* * The 10 digits: 0 to 9.
* * The space.
* * "!", "#", "$", "%", "&", "(", ")", "+", "-", ":", ";", "<", "=", ".", ">", "?", "@", "[", "]", "^", "_", " {", "}", "|", "~", ",".
*
* @param account The user account.
* @param role The user role.
*
* * Role_Publisher = 1: RECOMMENDED. Use this role for a voice/video call or a live broadcast.
* * Role_Subscriber = 2: ONLY use this role if your live-broadcast scenario requires authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in). In order for this role to take effect, please contact our support team to enable authentication for Hosting-in for you. Otherwise, Role_Subscriber still has the same privileges as Role_Publisher.
*
* @param privilegeTs represented by the number of seconds elapsed since 1/1/1970.
* If, for example, you want to access the Agora Service within 10 minutes
* after the token is generated, set expireTimestamp as the current time stamp
* + 600 (seconds).
*/
fun buildTokenWithUserAccount(
appId: String,
appCertificate: String,
channelName: String,
account: String,
privilegeTs: Int
): String {
// Assign appropriate access privileges to each role.
val builder = AccessToken(appId, appCertificate, channelName, account)
builder.addPrivilege(AccessToken.Privileges.JoinChannel, privilegeTs)
builder.addPrivilege(AccessToken.Privileges.PublishAudioStream, privilegeTs)
builder.addPrivilege(AccessToken.Privileges.PublishVideoStream, privilegeTs)
builder.addPrivilege(AccessToken.Privileges.PublishDataStream, privilegeTs)
return try {
builder.build()
} catch (e: Exception) {
e.printStackTrace()
""
}
}
}

View File

@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.agora
import kr.co.vividnext.sodalive.agora.AccessToken.Privileges
import org.springframework.stereotype.Component
@Component
class RtmTokenBuilder {
lateinit var mTokenCreator: AccessToken
@Throws(Exception::class)
fun buildToken(
appId: String,
appCertificate: String,
uid: String,
privilegeTs: Int
): String {
mTokenCreator = AccessToken(appId, appCertificate, uid, "")
mTokenCreator.addPrivilege(Privileges.RtmLogin, privilegeTs)
return mTokenCreator.build()
}
fun setPrivilege(privilege: Privileges?, expireTs: Int) {
mTokenCreator.addPrivilege(privilege!!, expireTs)
}
fun initTokenBuilder(originToken: String?): Boolean {
mTokenCreator.fromString(originToken!!)
return true
}
}

View File

@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.aws.cloudfront
import com.amazonaws.services.cloudfront.CloudFrontUrlSigner
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Paths
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
@Component
class AudioContentCloudFront(
@Value("\${cloud.aws.content-cloud-front.host}")
private val cloudfrontDomain: String,
@Value("\${cloud.aws.content-cloud-front.private-key-file-path}")
private val privateKeyFilePath: String,
@Value("\${cloud.aws.content-cloud-front.key-pair-id}")
private val keyPairId: String
) {
fun generateSignedURL(
resourcePath: String,
expirationTime: Long
): String {
// Load private key from file
val privateKey = loadPrivateKey(privateKeyFilePath)
// Generate signed URL for resource with custom policy and expiration time
return CloudFrontUrlSigner.getSignedURLWithCannedPolicy(
"$cloudfrontDomain/$resourcePath", // Resource URL
keyPairId, // CloudFront key pair ID
privateKey, // CloudFront private key
Date(System.currentTimeMillis() + expirationTime) // Expiration date
)
}
private fun loadPrivateKey(resourceName: String): PrivateKey {
val path = Paths.get(resourceName)
val bytes = Files.readAllBytes(path)
val keySpec = PKCS8EncodedKeySpec(bytes)
val keyFactory = KeyFactory.getInstance("RSA")
return keyFactory.generatePrivate(keySpec)
}
}

View File

@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.aws.s3
import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.model.ObjectMetadata
import com.amazonaws.services.s3.model.PutObjectRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.io.InputStream
@Component
class S3Uploader(private val amazonS3Client: AmazonS3Client) {
private val logger = LoggerFactory.getLogger(this::class.java)
fun upload(
inputStream: InputStream,
bucket: String,
filePath: String,
metadata: ObjectMetadata? = null
): String {
putS3(inputStream, bucket, filePath, metadata)
return filePath
}
private fun putS3(
inputStream: InputStream,
bucket: String,
filePath: String,
metadata: ObjectMetadata?
): String {
amazonS3Client.putObject(PutObjectRequest(bucket, filePath, inputStream, metadata))
logger.info("파일이 업로드 되었습니다.")
return amazonS3Client.getUrl(bucket, filePath).toString()
}
}

View File

@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@Entity
data class Can(
var title: String,
var can: Int,
var rewardCan: Int,
var price: Int,
@Enumerated(value = EnumType.STRING)
var status: CanStatus
) : BaseEntity()
enum class CanStatus {
SALE, END_OF_SALE
}

View File

@ -0,0 +1,60 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/can")
class CanController(private val service: CanService) {
@GetMapping
fun getCans(): ApiResponse<List<CanResponse>> {
return ApiResponse.ok(service.getCans())
}
@GetMapping("/status")
fun getCanStatus(
@RequestParam container: String,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.getCanStatus(member, container))
}
@GetMapping("/status/use")
fun getCanUseStatus(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestParam("timezone") timezone: String,
@RequestParam("container") container: String,
pageable: Pageable
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.getCanUseStatus(member, pageable, timezone, container))
}
@GetMapping("/status/charge")
fun getCanChargeStatus(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestParam("timezone") timezone: String,
@RequestParam("container") container: String,
pageable: Pageable
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.getCanChargeStatus(member, pageable, timezone, container))
}
}

View File

@ -0,0 +1,130 @@
package kr.co.vividnext.sodalive.can
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.QCan.can1
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
interface CanQueryRepository {
fun findAllByStatus(status: CanStatus): List<CanResponse>
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan?
}
@Repository
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
return queryFactory
.select(
QCanResponse(
can1.id,
can1.title,
can1.can,
can1.rewardCan,
can1.price
)
)
.from(can1)
.where(can1.status.eq(status))
.orderBy(can1.can.asc())
.fetch()
}
override fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> {
return queryFactory
.selectFrom(useCan)
.where(useCan.member.id.eq(member.id))
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(useCan.id.desc())
.fetch()
}
override fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge> {
val qMember = QMember.member
val chargeStatusCondition = when (container) {
"aos" -> {
charge.payment.paymentGateway.eq(PaymentGateway.PG)
.or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
}
"ios" -> {
charge.payment.paymentGateway.eq(PaymentGateway.PG)
.or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
}
else -> charge.payment.paymentGateway.eq(PaymentGateway.PG)
}
return queryFactory
.selectFrom(charge)
.innerJoin(charge.member, qMember)
.leftJoin(charge.useCan, useCan)
.leftJoin(charge.payment, payment)
.where(
qMember.id.eq(member.id)
.and(
payment.status.eq(PaymentStatus.COMPLETE)
.or(
charge.status.eq(ChargeStatus.REFUND_CHARGE)
.and(useCan.isNotNull)
)
.or(charge.status.eq(ChargeStatus.EVENT))
.or(charge.status.eq(ChargeStatus.ADMIN))
)
.and(chargeStatusCondition)
)
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(charge.id.desc())
.fetch()
}
override fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? {
return queryFactory
.selectFrom(useCan)
.innerJoin(useCan.member, member)
.innerJoin(useCan.room, liveRoom)
.where(
member.id.eq(memberId)
.and(liveRoom.id.eq(roomId))
.and(useCan.canUsage.eq(CanUsage.LIVE))
)
.orderBy(useCan.id.desc())
.fetchFirst()
}
override fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage): UseCan? {
return queryFactory
.selectFrom(useCan)
.innerJoin(useCan.member, member)
.innerJoin(useCan.room, liveRoom)
.where(
member.id.eq(memberId)
.and(liveRoom.id.eq(roomId))
.and(useCan.canUsage.eq(canUsage))
.and(useCan.isRefund.isFalse)
)
.orderBy(useCan.id.desc())
.fetchFirst()
}
}

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.can
import com.querydsl.core.annotations.QueryProjection
data class CanResponse @QueryProjection constructor(
val id: Long,
val title: String,
val can: Int,
val rewardCan: Int,
val price: Int
)

View File

@ -0,0 +1,118 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class CanService(private val repository: CanRepository) {
fun getCans(): List<CanResponse> {
return repository.findAllByStatus(status = CanStatus.SALE)
}
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
return GetCanStatusResponse(
chargeCan = member.getChargeCan(container),
rewardCan = member.getRewardCan(container)
)
}
fun getCanUseStatus(
member: Member,
pageable: Pageable,
timezone: String,
container: String
): List<GetCanUseStatusResponseItem> {
return repository.getCanUseStatus(member, pageable)
.filter { (it.can + it.rewardCan) > 0 }
.filter {
when (container) {
"aos" -> {
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
}
}
"ios" -> {
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
}
}
else -> it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG
}
}
}
.map {
val title: String = when (it.canUsage) {
CanUsage.DONATION -> {
"[후원] ${it.room!!.member!!.nickname}"
}
CanUsage.LIVE -> {
"[라이브] ${it.room!!.title}"
}
CanUsage.CHANGE_NICKNAME -> "닉네임 변경"
CanUsage.ORDER_CONTENT -> "콘텐츠 구매"
}
val createdAt = it.createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(timezone))
GetCanUseStatusResponseItem(
title = title,
date = createdAt.format(
DateTimeFormatter.ofPattern("yyyy-MM-dd | HH:mm:ss")
),
can = it.can + it.rewardCan
)
}
}
fun getCanChargeStatus(
member: Member,
pageable: Pageable,
timezone: String,
container: String
): List<GetCanChargeStatusResponseItem> {
return repository.getCanChargeStatus(member, pageable, container)
.map {
val canTitle = it.title ?: ""
val chargeMethod = when (it.status) {
ChargeStatus.CHARGE, ChargeStatus.EVENT -> {
it.payment!!.method ?: ""
}
ChargeStatus.REFUND_CHARGE -> {
"환불"
}
else -> {
"환불"
}
}
val createdAt = it.createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(timezone))
GetCanChargeStatusResponseItem(
canTitle = canTitle,
date = createdAt.format(
DateTimeFormatter.ofPattern("yyyy-MM-dd | HH:mm:ss")
),
chargeMethod = chargeMethod
)
}
}
}

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.can
data class GetCanChargeStatusResponseItem(
val canTitle: String,
val date: String,
val chargeMethod: String
)

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.can
data class GetCanStatusResponse(
val chargeCan: Int,
val rewardCan: Int
)

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.can
data class GetCanUseStatusResponseItem(
val title: String,
val date: String,
val can: Int
)

View File

@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.can.charge
import kr.co.vividnext.sodalive.can.Can
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.CascadeType
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToOne
@Entity
data class Charge(
var chargeCan: Int,
var rewardCan: Int,
@Enumerated(value = EnumType.STRING)
var status: ChargeStatus = ChargeStatus.CHARGE
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "can_id", nullable = true)
var can: Can? = null
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
@OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
@JoinColumn(name = "payment_id", nullable = true)
var payment: Payment? = null
set(value) {
value?.charge = this
field = value
}
@OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
@JoinColumn(name = "use_can_id", nullable = true)
var useCan: UseCan? = null
var title: String? = null
}

View File

@ -0,0 +1,52 @@
package kr.co.vividnext.sodalive.can.charge
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/charge")
class ChargeController(private val service: ChargeService) {
@PostMapping
fun charge(
@RequestBody chargeRequest: ChargeRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.charge(member, chargeRequest))
}
@PostMapping("/verify")
fun verify(
@RequestBody verifyRequest: VerifyRequest,
@AuthenticationPrincipal user: User
) = ApiResponse.ok(service.verify(user, verifyRequest))
@PostMapping("/apple")
fun appleCharge(
@RequestBody chargeRequest: AppleChargeRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.appleCharge(member, chargeRequest))
}
@PostMapping("/apple/verify")
fun appleVerify(
@RequestBody verifyRequest: AppleVerifyRequest,
@AuthenticationPrincipal user: User
) = ApiResponse.ok(service.appleVerify(user, verifyRequest))
}

View File

@ -0,0 +1,35 @@
package kr.co.vividnext.sodalive.can.charge
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
data class ChargeResponse(val chargeId: Long)
data class VerifyRequest(
@JsonProperty("receipt_id")
val receiptId: String,
@JsonProperty("order_id")
val orderId: String
)
data class VerifyResult(
@JsonProperty("receipt_id")
val receiptId: String,
val method: String,
val status: Int,
val price: Int
)
data class AppleChargeRequest(
val title: String,
val chargeCan: Int,
val paymentGateway: PaymentGateway,
var price: Double? = null,
var locale: String? = null
)
data class AppleVerifyRequest(val receiptString: String, val chargeId: Long)
data class AppleVerifyResponse(val status: Int)

View File

@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.can.charge
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface ChargeRepository : JpaRepository<Charge, Long>, ChargeQueryRepository
interface ChargeQueryRepository {
fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
}
class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ChargeQueryRepository {
override fun getOldestChargeWhereRewardCanGreaterThan0(
chargeId: Long,
memberId: Long,
container: String
): Charge? {
return queryFactory
.selectFrom(charge)
.innerJoin(charge.member, member)
.leftJoin(charge.payment, payment)
.where(
member.id.eq(memberId)
.and(charge.rewardCan.gt(0))
.and(charge.id.gt(chargeId))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(getPaymentGatewayCondition(container))
)
.orderBy(charge.id.asc())
.fetchFirst()
}
override fun getOldestChargeWhereChargeCanGreaterThan0(
chargeId: Long,
memberId: Long,
container: String
): Charge? {
return queryFactory
.selectFrom(charge)
.innerJoin(charge.member, member)
.leftJoin(charge.payment, payment)
.where(
member.id.eq(memberId)
.and(charge.chargeCan.gt(0))
.and(charge.id.gt(chargeId))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(getPaymentGatewayCondition(container))
)
.orderBy(charge.id.asc())
.fetchFirst()
}
private fun getPaymentGatewayCondition(container: String): BooleanExpression? {
val paymentGatewayCondition = when (container) {
"aos" -> {
payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
}
"ios" -> {
payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
}
else -> payment.paymentGateway.eq(PaymentGateway.PG)
}
return paymentGatewayCondition
}
}

View File

@ -0,0 +1,203 @@
package kr.co.vividnext.sodalive.can.charge
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.bootpay.Bootpay
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpHeaders
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class ChargeService(
private val chargeRepository: ChargeRepository,
private val canRepository: CanRepository,
private val memberRepository: MemberRepository,
private val objectMapper: ObjectMapper,
private val okHttpClient: OkHttpClient,
@Value("\${bootpay.application-id}")
private val bootpayApplicationId: String,
@Value("\${bootpay.private-key}")
private val bootpayPrivateKey: String,
@Value("\${apple.iap-verify-sandbox-url}")
private val appleInAppVerifySandBoxUrl: String,
@Value("\${apple.iap-verify-url}")
private val appleInAppVerifyUrl: String
) {
@Transactional
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
val can = canRepository.findByIdOrNull(request.canId)
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
val charge = Charge(can.can, can.rewardCan)
charge.title = can.title
charge.member = member
charge.can = can
val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = can.price.toDouble()
charge.payment = payment
chargeRepository.save(charge)
return ChargeResponse(chargeId = charge.id!!)
}
@Transactional
fun verify(user: User, verifyRequest: VerifyRequest) {
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByEmail(user.username)
?: throw SodaException("로그인 정보를 확인해주세요.")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)
try {
bootpay.accessToken
val verifyResult = objectMapper.convertValue(
bootpay.getReceipt(verifyRequest.receiptId),
VerifyResult::class.java
)
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = verifyResult.method
charge.payment?.status = PaymentStatus.COMPLETE
member.charge(charge.chargeCan, charge.rewardCan, "pg")
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (e: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
@Transactional
fun appleCharge(member: Member, request: AppleChargeRequest): ChargeResponse {
val charge = Charge(request.chargeCan, 0)
charge.title = request.title
charge.member = member
val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = if (request.price != null) {
request.price!!
} else {
0.toDouble()
}
payment.locale = request.locale
charge.payment = payment
chargeRepository.save(charge)
return ChargeResponse(chargeId = charge.id!!)
}
@Transactional
fun appleVerify(user: User, verifyRequest: AppleVerifyRequest) {
val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId)
?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByEmail(user.username)
?: throw SodaException("로그인 정보를 확인해주세요.")
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
// 검증로직
if (requestRealServerVerify(verifyRequest)) {
charge.payment?.receiptId = verifyRequest.receiptString
charge.payment?.method = "애플(인 앱 결제)"
charge.payment?.status = PaymentStatus.COMPLETE
member.charge(charge.chargeCan, charge.rewardCan, "ios")
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
private fun requestRealServerVerify(verifyRequest: AppleVerifyRequest): Boolean {
val body = JSONObject()
body.put("receipt-data", verifyRequest.receiptString)
val request = Request.Builder()
.url(appleInAppVerifyUrl)
.addHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.post(body.toString().toRequestBody("application/json".toMediaTypeOrNull()))
.build()
val response = okHttpClient.newCall(request).execute()
if (response.isSuccessful) {
val responseString = response.body?.string()
if (responseString != null) {
val verifyResult = objectMapper.readValue(responseString, AppleVerifyResponse::class.java)
return when (verifyResult.status) {
0 -> {
true
}
21007 -> {
requestSandboxServerVerify(verifyRequest)
}
else -> {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
} else {
throw SodaException("결제를 완료하지 못했습니다.")
}
} else {
throw SodaException("결제를 완료하지 못했습니다.")
}
}
private fun requestSandboxServerVerify(verifyRequest: AppleVerifyRequest): Boolean {
val body = JSONObject()
body.put("receipt-data", verifyRequest.receiptString)
val request = Request.Builder()
.url(appleInAppVerifySandBoxUrl)
.addHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.post(body.toString().toRequestBody("application/json".toMediaTypeOrNull()))
.build()
val response = okHttpClient.newCall(request).execute()
if (response.isSuccessful) {
val responseString = response.body?.string()
if (responseString != null) {
val verifyResult = objectMapper.readValue(responseString, AppleVerifyResponse::class.java)
return when (verifyResult.status) {
0 -> {
true
}
else -> {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
} else {
throw SodaException("결제를 완료하지 못했습니다.")
}
} else {
throw SodaException("결제를 완료하지 못했습니다.")
}
}
}

View File

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

View File

@ -0,0 +1,298 @@
package kr.co.vividnext.sodalive.can.payment
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.SpentCan
import kr.co.vividnext.sodalive.can.use.TotalSpentCan
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class CanPaymentService(
private val repository: CanRepository,
private val memberRepository: MemberRepository,
private val chargeRepository: ChargeRepository,
private val useCanRepository: UseCanRepository,
private val useCanCalculateRepository: UseCanCalculateRepository
) {
@Transactional
fun spendCan(
memberId: Long,
needCan: Int,
canUsage: CanUsage,
liveRoom: LiveRoom? = null,
order: Order? = null,
audioContent: AudioContent? = null,
container: String
) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val useRewardCan = spendRewardCan(memberId, needCan, container)
val useChargeCan = if (needCan - useRewardCan.total > 0) {
spendChargeCan(memberId, needCan = needCan - useRewardCan.total, container = container)
} else {
null
}
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
throw SodaException(
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
"캔이 부족합니다. 충전 후 이용해 주세요."
)
}
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
}
val useCan = UseCan(
canUsage = canUsage,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total
)
var recipientId: Long? = null
if (canUsage == CanUsage.LIVE && liveRoom != null) {
recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom
useCan.member = member
} else if (canUsage == CanUsage.CHANGE_NICKNAME) {
useCan.member = member
} else if (canUsage == CanUsage.DONATION && liveRoom != null) {
recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom
useCan.member = member
} else if (canUsage == CanUsage.ORDER_CONTENT && order != null) {
recipientId = order.creator!!.id!!
useCan.order = order
useCan.member = member
} else if (canUsage == CanUsage.DONATION && audioContent != null) {
recipientId = audioContent.member!!.id!!
useCan.audioContent = audioContent
useCan.member = member
} else {
throw SodaException("잘못된 요청입니다.")
}
useCanRepository.save(useCan)
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
setUseCanCalculate(
recipientId,
useRewardCan,
useChargeCan,
useCan,
paymentGateway = PaymentGateway.GOOGLE_IAP
)
setUseCanCalculate(
recipientId,
useRewardCan,
useChargeCan,
useCan,
paymentGateway = PaymentGateway.APPLE_IAP
)
}
private fun setUseCanCalculate(
recipientId: Long?,
useRewardCan: TotalSpentCan,
useChargeCan: TotalSpentCan?,
useCan: UseCan,
paymentGateway: PaymentGateway
) {
val totalSpentRewardCan = useRewardCan.spentCans
.filter { it.paymentGateway == paymentGateway }
.fold(0) { sum, spentCans -> sum + spentCans.can }
val useCanCalculate = if (useChargeCan != null) {
val totalSpentChargeCan = useChargeCan.spentCans
.filter { it.paymentGateway == paymentGateway }
.fold(0) { sum, spentCans -> sum + spentCans.can }
UseCanCalculate(
can = totalSpentChargeCan + totalSpentRewardCan,
paymentGateway = paymentGateway,
status = UseCanCalculateStatus.RECEIVED
)
} else {
UseCanCalculate(
can = totalSpentRewardCan,
paymentGateway = paymentGateway,
status = UseCanCalculateStatus.RECEIVED
)
}
if (useCanCalculate.can > 0) {
useCanCalculate.useCan = useCan
useCanCalculate.recipientCreatorId = recipientId
useCanCalculateRepository.save(useCanCalculate)
}
}
private fun spendRewardCan(memberId: Long, needCan: Int, container: String): TotalSpentCan {
return if (needCan > 0) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val spentCans = mutableListOf<SpentCan>()
var chargeId = 0L
var total = 0
while (needCan - total > 0) {
val remainingNeedCan = needCan - total
val charge = chargeRepository.getOldestChargeWhereRewardCanGreaterThan0(chargeId, memberId, container)
?: break
if (charge.rewardCan >= remainingNeedCan) {
charge.rewardCan -= remainingNeedCan
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.googleRewardCan -= remainingNeedCan
}
total += remainingNeedCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = remainingNeedCan
)
)
} else {
total += charge.rewardCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = charge.rewardCan
)
)
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.googleRewardCan -= remainingNeedCan
}
charge.rewardCan = 0
}
chargeId = charge.id!!
}
TotalSpentCan(spentCans, total)
} else {
TotalSpentCan(total = 0)
}
}
private fun spendChargeCan(memberId: Long, needCan: Int, container: String): TotalSpentCan {
return if (needCan > 0) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val spentCans = mutableListOf<SpentCan>()
var chargeId = 0L
var total = 0
while (needCan - total > 0) {
val remainingNeedCan = needCan - total
val charge = chargeRepository.getOldestChargeWhereChargeCanGreaterThan0(chargeId, memberId, container)
?: break
if (charge.chargeCan >= remainingNeedCan) {
charge.chargeCan -= remainingNeedCan
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgChargeCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleChargeCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.googleChargeCan -= remainingNeedCan
}
total += remainingNeedCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = remainingNeedCan
)
)
} else {
total += charge.chargeCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = charge.chargeCan
)
)
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgChargeCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleChargeCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.pgChargeCan -= remainingNeedCan
}
charge.chargeCan = 0
}
chargeId = charge.id!!
}
TotalSpentCan(spentCans, total)
} else {
TotalSpentCan(total = 0)
}
}
@Transactional
fun refund(memberId: Long, roomId: Long) {
val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException("잘못된 예약정보 입니다.")
val useCan = repository.getCanUsedForLiveRoomNotRefund(
memberId = memberId,
roomId = roomId,
canUsage = CanUsage.LIVE
) ?: throw SodaException("잘못된 예약정보 입니다.")
useCan.isRefund = true
val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
useCanCalculates.forEach {
it.status = UseCanCalculateStatus.REFUND
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
charge.title = "${it.can}"
charge.useCan = useCan
when (it.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan += charge.rewardCan
PaymentGateway.GOOGLE_IAP -> member.googleRewardCan += charge.rewardCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan += charge.rewardCan
}
charge.member = member
val payment = Payment(
status = PaymentStatus.COMPLETE,
paymentGateway = it.paymentGateway
)
payment.method = "환불"
charge.payment = payment
chargeRepository.save(charge)
}
}
}

View File

@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.can.payment
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.OneToOne
@Entity
data class Payment(
@Enumerated(value = EnumType.STRING)
var status: PaymentStatus = PaymentStatus.REQUEST,
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
val paymentGateway: PaymentGateway
) : BaseEntity() {
@OneToOne(mappedBy = "payment", fetch = FetchType.LAZY)
var charge: Charge? = null
@Column(columnDefinition = "TEXT", nullable = true)
var receiptId: String? = null
var method: String? = null
var price: Double = 0.toDouble()
var locale: String? = null
}
enum class PaymentStatus {
// 결제요청
REQUEST,
// 결제완료
COMPLETE,
// 환불
RETURN
}

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.can.payment
enum class PaymentGateway {
PG, GOOGLE_IAP, APPLE_IAP
}

Some files were not shown because too many files have changed in this diff Show More