diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml new file mode 100644 index 0000000..4e8c86b --- /dev/null +++ b/.github/workflows/cd-dev.yml @@ -0,0 +1,84 @@ +name: Java CI with Gradle + +on: + push: + branches: [ "dev" ] + +jobs: + build: + ## checkout후 자바 21 버전으로 설정을 합니다 + runs-on: ubuntu-latest + env: + DB_URL: ${{ secrets.DB_URL }} + DB_USER: ${{ secrets.DB_USER }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + JASYPT_ENCRYPTOR_PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }} + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/gradle-build-action@v2 + + - name: Increase Gradle memory settings + run: | + echo "org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx4g" >> ~/.gradle/gradle.properties + + ## gradlew 의 권한을 줍니다. + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + ## gradle build + - name: Build with Gradle + run: ./gradlew clean build -x test + + ## 이미지 태그에 시간 설정을 하기위해서 현재 시간을 가져옵니다. + - name: Get current time + uses: 1466587594/get-current-time@v2 + id: current-time + with: + format: YYYY-MM-DDTHH-mm-ss + utcOffset: "+09:00" + + - name: Show Current Time + run: echo "CurrentTime=${{steps.current-time.outputs.formattedTime}}" + ## AWS에 로그인. aws-region은 서울로 설정(ap-northeast-2) + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + ## ECR에 로그인 + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + ## sample라는 ECR 리파지터리에 현재 시간 태그를 생성하고, 푸쉬 + ## 앞의 스탭에서 ${{steps.current-time.outputs.formattedTime}}로 현재 시간을 가져옵니다. + - name: Build, tag, and push image to Amazon ECR + run: | + docker build --build-arg PASSWORD=$PASSWORD -t polabo:${{steps.current-time.outputs.formattedTime}} . + docker tag polabo:${{steps.current-time.outputs.formattedTime}} ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + docker push ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + env: + PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }} + + - name: Deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST_DEV }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_KEY }} + script: | + aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }}/${{ secrets.ECR_REPOSITORY }} + docker stop polabo-dev + docker rm polabo-dev + docker pull ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + docker run -d -v /etc/localtime:/etc/localtime:ro -v /usr/share/zoneinfo/Asia/Seoul:/etc/timezone:ro -e ENVIRONMENT_VALUE=-Dspring.profiles.active=dev --name polabo-dev -p 8080:8080 --restart=always --network host ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml new file mode 100644 index 0000000..78ea501 --- /dev/null +++ b/.github/workflows/cd-prod.yml @@ -0,0 +1,84 @@ +name: Java CI with Gradle + +on: + push: + branches: [ "main" ] + +jobs: + build: + ## checkout후 자바 21 버전으로 설정을 합니다 + runs-on: ubuntu-latest + env: + DB_URL: ${{ secrets.DB_URL }} + DB_USER: ${{ secrets.DB_USER }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + JASYPT_ENCRYPTOR_PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }} + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/gradle-build-action@v2 + + - name: Increase Gradle memory settings + run: | + echo "org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx4g" >> ~/.gradle/gradle.properties + + ## gradlew 의 권한을 줍니다. + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + ## gradle build + - name: Build with Gradle + run: ./gradlew clean build -x test + + ## 이미지 태그에 시간 설정을 하기위해서 현재 시간을 가져옵니다. + - name: Get current time + uses: 1466587594/get-current-time@v2 + id: current-time + with: + format: YYYY-MM-DDTHH-mm-ss + utcOffset: "+09:00" + + - name: Show Current Time + run: echo "CurrentTime=${{steps.current-time.outputs.formattedTime}}" + ## AWS에 로그인. aws-region은 서울로 설정(ap-northeast-2) + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + ## ECR에 로그인 + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + ## sample라는 ECR 리파지터리에 현재 시간 태그를 생성하고, 푸쉬 + ## 앞의 스탭에서 ${{steps.current-time.outputs.formattedTime}}로 현재 시간을 가져옵니다. + - name: Build, tag, and push image to Amazon ECR + run: | + docker build --build-arg PASSWORD=$PASSWORD -t polabo:${{steps.current-time.outputs.formattedTime}} . + docker tag polabo:${{steps.current-time.outputs.formattedTime}} ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + docker push ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + env: + PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }} + + - name: Deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_KEY }} + script: | + aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }}/${{ secrets.ECR_REPOSITORY }} + docker stop polabo-dev + docker rm polabo-dev + docker pull ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + docker run -d -v /etc/localtime:/etc/localtime:ro -v /usr/share/zoneinfo/Asia/Seoul:/etc/timezone:ro -e ENVIRONMENT_VALUE=-Dspring.profiles.active=dev --name polabo-dev -p 8080:8080 --restart=always --network host ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8bbf32d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM openjdk:21 + +ARG JAR_FILE=build/libs/*SNAPSHOT.jar +ARG PASSWORD + +COPY ${JAR_FILE} polabo.jar + +ENV JASYPT_ENCRYPTOR_PASSWORD=${PASSWORD} + +#ENTRYPOINT ["sh", "-c", "java -jar /polabo.jar -Djasypt.encryptor.password=${JASYPT_ENCRYPTOR_PASSWORD}"] +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} ${ENVIRONMENT_VALUE} -jar /polabo.jar", "-Djasypt.encryptor.password=${JASYPT_ENCRYPTOR_PASSWORD}"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 170e646..2058318 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,5 @@ -import org.jetbrains.kotlin.gradle.idea.proto.com.google.protobuf.GeneratedCodeInfoKt.annotation +import nu.studer.gradle.jooq.JooqEdition +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { val kotlinVersion = "1.9.24" @@ -6,9 +7,10 @@ plugins { id("io.spring.dependency-management") version "1.1.5" kotlin("jvm") version kotlinVersion kotlin("plugin.spring") version kotlinVersion - kotlin("plugin.jpa") version kotlinVersion - kotlin("plugin.allopen") version kotlinVersion +// kotlin("plugin.jpa") version kotlinVersion +// kotlin("plugin.allopen") version kotlinVersion kotlin("kapt") version kotlinVersion + id("nu.studer.jooq") version "9.0" } group = "com.ddd" @@ -16,16 +18,53 @@ version = "0.0.1-SNAPSHOT" java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion.set(JavaLanguageVersion.of(21)) } } -allOpen { - annotation("jakarta.persistence.Entity") - annotation("jakarta.persistence.MappedSuperclass") - annotation("jakarta.persistence.Embeddable") +jooq { + version.set("3.18.10") + edition.set(JooqEdition.OSS) + + configurations { + create("main") { + generateSchemaSourceOnCompilation.set(true) + jooqConfiguration.apply { + logging = org.jooq.meta.jaxb.Logging.WARN + jdbc.apply { + driver = "com.mysql.cj.jdbc.Driver" + url = System.getenv("DB_URL") ?: "jdbc:mysql://localhost:3306/polabo" + user = System.getenv("DB_USER") ?: "polabo" + password = System.getenv("DB_PASSWORD") ?: "polabo" + } + generator.apply { + name = "org.jooq.codegen.KotlinGenerator" + database.apply { + name = "org.jooq.meta.mysql.MySQLDatabase" + excludes = "sys" + } + generate.apply { + isDeprecated = false + isFluentSetters = true + isRecords = true + } + target.apply { + packageName = "com.ddd.sonnypolabobe.jooq" + directory = "build/generated-src/jooq/main" + } + strategy.name = "org.jooq.codegen.DefaultGeneratorStrategy" + } + } + } + } } +//allOpen { +// annotation("jakarta.persistence.Entity") +// annotation("jakarta.persistence.MappedSuperclass") +// annotation("jakarta.persistence.Embeddable") +//} + repositories { mavenCentral() } @@ -34,17 +73,35 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") -// runtimeOnly("org.mariadb.jdbc:mariadb-java-client") // implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.jetbrains.kotlin:kotlin-reflect") + runtimeOnly("com.mysql:mysql-connector-j") + implementation("org.springframework.boot:spring-boot-starter-jooq") + jooqGenerator("com.mysql:mysql-connector-j") + jooqGenerator("org.jooq:jooq-meta:3.18.10") + jooqGenerator("org.jooq:jooq-codegen:3.18.10") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation ("com.github.f4b6a3:uuid-creator:5.3.3") + implementation("software.amazon.awssdk:s3:2.20.68") + implementation("com.amazonaws:aws-java-sdk-s3:1.12.561") + implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64") + + implementation("io.jsonwebtoken:jjwt-api:0.11.2") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2") } -kotlin { - compilerOptions { - freeCompilerArgs.addAll("-Xjsr305=strict") +tasks.withType { + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + jvmTarget = "21" + kotlinDaemonJvmArguments = listOf("-Xmx4096m", "-Xms2560m", "-XX:+UseParallelGC") } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d8507bf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.7' + +services: + mysql: + image: mysql:latest + container_name: polabo_mysql + hostname: polabo_mysql + volumes: + - ./mysqldata:/var/lib/mysql + environment: + - MYSQL_USER=polabo + - MYSQL_PASSWORD=polabo + - MYSQL_ROOT_PASSWORD=polabo + - MYSQL_HOST=localhost + - MYSQL_PORT=3306 + - MYSQL_DATABASE=polabo + ports: + - "3306:3306" + + +# redis: +# image: redis +# container_name: polabo_redis +# hostname: polabo_redis +# ports: +# - "6379:6379" \ No newline at end of file diff --git a/gradle/gradle.properties b/gradle/gradle.properties new file mode 100644 index 0000000..4fa9d49 --- /dev/null +++ b/gradle/gradle.properties @@ -0,0 +1,2 @@ +# Increase the maximum heap size for the Kotlin daemon +kotlin.daemon.jvmargs=-Xmx4g diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a441313..dab2a01 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -4,4 +4,4 @@ distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/SonnyPolaboBeApplication.kt b/src/main/kotlin/com/ddd/sonnypolabobe/SonnyPolaboBeApplication.kt index c3ec9a2..bafe9f3 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/SonnyPolaboBeApplication.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/SonnyPolaboBeApplication.kt @@ -1,11 +1,14 @@ package com.ddd.sonnypolabobe +import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling +@EnableScheduling @SpringBootApplication class SonnyPolaboBeApplication - +inline fun T.logger() = LoggerFactory.getLogger(T::class.java)!! fun main(args: Array) { runApplication(*args) } diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/BoardController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/BoardController.kt new file mode 100644 index 0000000..e3084b5 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/BoardController.kt @@ -0,0 +1,58 @@ +package com.ddd.sonnypolabobe.domain.board.controller + +import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardCreateRequest +import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardGetResponse +import com.ddd.sonnypolabobe.domain.board.service.BoardService +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import com.ddd.sonnypolabobe.logger +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.context.SecurityContextHolder +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@Tag(name = "Board API", description = "보드 관련 API") +@RestController +@RequestMapping("/api/v1/boards") +class BoardController( + private val boardService: BoardService +) { + @Operation(summary = "보드 생성", description = """ + 보드를 생성합니다. + userId는 추후 회원가입 기능이 추가될 것을 대비한 것입니다. 지금은 null로 주세요. + + userId 데이터는 백에서 채울 것입니다.! + """) + @PostMapping + fun create(@RequestBody request : BoardCreateRequest) + : ApplicationResponse { + val user = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + request.userId = user.id + return ApplicationResponse.ok(this.boardService.create(request)) + } + + @Operation(summary = "보드 조회", description = """ + 보드를 조회합니다. + """) + @GetMapping("/{id}") + fun get(@PathVariable id : String) + = ApplicationResponse.ok(this.boardService.getById(id)) + + @Operation(summary = "보드 누적 생성 수 조회", description = """ + 보드 누적 생성 수를 조회합니다. + """) + @GetMapping("/total-count") + fun getTotalCount() = ApplicationResponse.ok(this.boardService.getTotalCount()) + + @Operation(summary = "오늘 생성 가능한 보드 수 조회", description = """ + 오늘 생성 가능한 보드 수를 조회합니다. + """) + @GetMapping("/create-available") + fun createAvailable() = ApplicationResponse.ok(this.boardService.createAvailable()) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardCreateRequest.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardCreateRequest.kt new file mode 100644 index 0000000..422042b --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardCreateRequest.kt @@ -0,0 +1,15 @@ +package com.ddd.sonnypolabobe.domain.board.controller.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import java.util.* + +data class BoardCreateRequest( + @Schema(description = "제목", example = "쏘니의 보드") + @field:NotBlank + @field:Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*()_+=-])(?=.*[ㄱ-ㅎㅏ-ㅣ가-힣]).{1,20}$", message = "제목은 국문, 영문, 숫자, 특수문자, 띄어쓰기를 포함한 20자 이내여야 합니다.") + val title: String, + @Schema(description = "작성자 아이디", example = "null", required = false) + var userId: Long? = null +) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardGetResponse.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardGetResponse.kt new file mode 100644 index 0000000..a39545f --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardGetResponse.kt @@ -0,0 +1,11 @@ +package com.ddd.sonnypolabobe.domain.board.controller.dto + +import com.ddd.sonnypolabobe.domain.polaroid.controller.dto.PolaroidGetResponse +import io.swagger.v3.oas.annotations.media.Schema + +data class BoardGetResponse( + @Schema(description = "제목", example = "쏘니의 보드") + val title: String, + @Schema(description = "작성자", example = "작성자입니다.") + val items: List +) \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/entity/BoardEntity.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/entity/BoardEntity.kt new file mode 100644 index 0000000..439bb3f --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/entity/BoardEntity.kt @@ -0,0 +1,15 @@ +package com.ddd.sonnypolabobe.domain.board.entity + +import com.ddd.sonnypolabobe.global.entity.BaseEntity +import com.ddd.sonnypolabobe.global.util.UuidGenerator +import java.time.LocalDateTime +import java.util.* + +class BoardEntity() : BaseEntity { + override val id: UUID = UuidGenerator.create() + var title: String = "" + var userId : UUID? = null + override var yn: Boolean = true + override val createdAt: LocalDateTime = LocalDateTime.now() + override var updatedAt: LocalDateTime = LocalDateTime.now() +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/controller/MyBoardController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/controller/MyBoardController.kt new file mode 100644 index 0000000..10522e3 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/controller/MyBoardController.kt @@ -0,0 +1,59 @@ +package com.ddd.sonnypolabobe.domain.board.my.controller + +import com.ddd.sonnypolabobe.domain.board.my.dto.MyBoardDto +import com.ddd.sonnypolabobe.domain.board.my.service.MyBoardService +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.entity.PageDto +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/my/boards") +class MyBoardController(private val myBoardService : MyBoardService) { + + @Operation(summary = "내 보드 목록 조회", description = """ + 내 보드 목록을 조회합니다. + """) + @GetMapping + fun getMyBoards( + @RequestParam(name = "page", defaultValue = "1") page : Int, + @RequestParam size : Int + ) : ApplicationResponse> { + val user = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + return ApplicationResponse.ok(this.myBoardService.getMyBoards(user.id, page, size)) + } + + + @Operation( + summary = "내 보드 이름 수정", + description = """ + 내 보드 이름을 수정합니다. + """ + ) + @PutMapping("/{id}") + fun updateMyBoard( + @PathVariable id : String, + @RequestBody request : MyBoardDto.Companion.MBUpdateReq + ) : ApplicationResponse { + val userId = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + this.myBoardService.updateMyBoard(id, request, userId.id) + return ApplicationResponse.ok() + } + + @Operation( + summary = "내 보드 삭제", + description = """ + 내 보드를 삭제합니다. + """ + ) + @DeleteMapping("/{id}") + fun deleteMyBoard( + @PathVariable id : String + ) : ApplicationResponse { + val userId = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + this.myBoardService.deleteMyBoard(id, userId.id) + return ApplicationResponse.ok() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/dto/MyBoardDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/dto/MyBoardDto.kt new file mode 100644 index 0000000..91ce2eb --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/dto/MyBoardDto.kt @@ -0,0 +1,31 @@ +package com.ddd.sonnypolabobe.domain.board.my.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import org.springframework.format.annotation.DateTimeFormat +import java.time.LocalDateTime +import java.util.UUID + +class MyBoardDto { + companion object { + data class MBUpdateReq( + @JsonProperty("title") + val title: String + ) + + data class PageListRes( + val id: UUID, + val title: String, + @DateTimeFormat(pattern = "yyyy-MM-dd", iso = DateTimeFormat.ISO.DATE) + val createdAt: LocalDateTime, + ) + + data class GetOneRes( + val id: UUID, + val title: String, + @DateTimeFormat(pattern = "yyyy-MM-dd", iso = DateTimeFormat.ISO.DATE) + val createdAt: LocalDateTime, + val userId : Long? + ) + + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/service/MyBoardService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/service/MyBoardService.kt new file mode 100644 index 0000000..6382951 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/service/MyBoardService.kt @@ -0,0 +1,35 @@ +package com.ddd.sonnypolabobe.domain.board.my.service + +import com.ddd.sonnypolabobe.domain.board.my.dto.MyBoardDto +import com.ddd.sonnypolabobe.domain.board.repository.BoardJooqRepository +import com.ddd.sonnypolabobe.global.entity.PageDto +import com.ddd.sonnypolabobe.global.util.UuidConverter +import org.springframework.stereotype.Service + +@Service +class MyBoardService(private val boardJooqRepository: BoardJooqRepository) { + fun updateMyBoard(id: String, request: MyBoardDto.Companion.MBUpdateReq, userId: Long) { + val board = this.boardJooqRepository.findById(UuidConverter.stringToUUID(id)) + ?: throw IllegalArgumentException("해당 보드가 존재하지 않습니다.") + if (board.userId != userId) { + throw IllegalArgumentException("해당 보드에 대한 권한이 없습니다.") + } + this.boardJooqRepository.updateTitle(UuidConverter.stringToUUID(id), request.title) + } + + fun deleteMyBoard(id: String, userId: Long) { + val board = this.boardJooqRepository.findById(UuidConverter.stringToUUID(id)) + ?: throw IllegalArgumentException("해당 보드가 존재하지 않습니다.") + if (board.userId != userId) { + throw IllegalArgumentException("해당 보드에 대한 권한이 없습니다.") + } + this.boardJooqRepository.delete(UuidConverter.stringToUUID(id)) + } + + fun getMyBoards(userId: Long, page: Int, size: Int): PageDto { + val data = this.boardJooqRepository.findAllByUserId(userId, page-1, size) + val totalCount = this.boardJooqRepository.selectTotalCountByUserId(userId) + + return PageDto(data, totalCount, page, size) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepository.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepository.kt new file mode 100644 index 0000000..1520e6f --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepository.kt @@ -0,0 +1,20 @@ +package com.ddd.sonnypolabobe.domain.board.repository + +import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardCreateRequest +import com.ddd.sonnypolabobe.domain.board.my.dto.MyBoardDto +import com.ddd.sonnypolabobe.jooq.polabo.tables.Board +import org.jooq.Record6 +import java.time.LocalDateTime +import java.util.* + +interface BoardJooqRepository { + fun insertOne(request: BoardCreateRequest): ByteArray? + fun selectOneById(id: UUID) : Array> + fun selectTotalCount(): Long + fun selectTodayTotalCount(): Long + fun findById(id: UUID): MyBoardDto.Companion.GetOneRes? + fun updateTitle(id: UUID, title: String) + fun delete(id: UUID) + fun findAllByUserId(userId: Long, page: Int, size: Int): List + fun selectTotalCountByUserId(userId: Long): Long +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepositoryImpl.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepositoryImpl.kt new file mode 100644 index 0000000..0dfaa2b --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepositoryImpl.kt @@ -0,0 +1,152 @@ +package com.ddd.sonnypolabobe.domain.board.repository + +import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardCreateRequest +import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardGetResponse +import com.ddd.sonnypolabobe.domain.board.my.dto.MyBoardDto +import com.ddd.sonnypolabobe.global.util.DateConverter +import com.ddd.sonnypolabobe.global.util.UuidConverter +import com.ddd.sonnypolabobe.global.util.UuidGenerator +import com.ddd.sonnypolabobe.jooq.polabo.tables.Board +import com.ddd.sonnypolabobe.jooq.polabo.tables.Polaroid +import org.jooq.DSLContext +import org.jooq.Record6 +import org.springframework.stereotype.Repository +import java.time.LocalDateTime +import java.util.* + +@Repository +class BoardJooqRepositoryImpl( + private val dslContext: DSLContext +) : BoardJooqRepository { + override fun insertOne(request: BoardCreateRequest): ByteArray? { + val jBoard = Board.BOARD + val id = UuidConverter.uuidToByteArray(UuidGenerator.create()) + val insertValue = jBoard.newRecord().apply { + this.id = id + this.title = request.title + this.createdAt = DateConverter.convertToKst(LocalDateTime.now()) + this.yn = 1 + this.activeyn = 1 + this.userId = request.userId + } + val result = this.dslContext.insertInto(jBoard) + .set(insertValue) + .execute() + + return if (result == 1) id else null + } + + override fun selectOneById(id: UUID): Array> { + val jBoard = Board.BOARD + val jPolaroid = Polaroid.POLAROID + return this.dslContext + .select( + jBoard.TITLE, + jPolaroid.ID, + jPolaroid.IMAGE_KEY, + jPolaroid.ONE_LINE_MESSAGE, + jPolaroid.CREATED_AT, + jPolaroid.USER_ID + ) + .from(jBoard) + .leftJoin(jPolaroid).on( + jBoard.ID.eq(jPolaroid.BOARD_ID).and(jPolaroid.YN.eq(1)) + .and(jPolaroid.ACTIVEYN.eq(1)) + ) + .where( + jBoard.ID.eq(UuidConverter.uuidToByteArray(id)).and(jBoard.YN.eq(1)) + .and(jBoard.ACTIVEYN.eq(1)) + ) + .orderBy(jPolaroid.CREATED_AT.desc()) + .fetchArray() + + } + + override fun selectTotalCount(): Long { + val jBoard = Board.BOARD + return this.dslContext + .selectCount() + .from(jBoard) + .fetchOne(0, Long::class.java) ?: 0 + } + + override fun selectTodayTotalCount(): Long { + val jBoard = Board.BOARD + return this.dslContext + .selectCount() + .from(jBoard) + .where( + jBoard.CREATED_AT.greaterOrEqual(DateConverter.convertToKst(LocalDateTime.now().withHour(0).withMinute(0).withSecond(0))) + .and(jBoard.CREATED_AT.lessThan(DateConverter.convertToKst(LocalDateTime.now().withHour(23).withMinute(59).withSecond(59))) + .and(jBoard.YN.eq(1)) + .and(jBoard.ACTIVEYN.eq(1)) + )) + .fetchOne(0, Long::class.java) ?: 0L + } + + override fun findById(id: UUID): MyBoardDto.Companion.GetOneRes? { + val jBoard = Board.BOARD + return this.dslContext.selectFrom(jBoard) + .where(jBoard.ID.eq(UuidConverter.uuidToByteArray(id))) + .fetchOne()?.map { + MyBoardDto.Companion.GetOneRes( + id = UuidConverter.byteArrayToUUID(it.get("id", ByteArray::class.java)!!), + title = it.get("title", String::class.java)!!, + createdAt = it.get("created_at", LocalDateTime::class.java)!!, + userId = it.get("user_id", Long::class.java), + ) + } + } + + override fun updateTitle(id: UUID, title: String) { + val jBoard = Board.BOARD + this.dslContext.update(jBoard) + .set(jBoard.TITLE, title) + .where(jBoard.ID.eq(UuidConverter.uuidToByteArray(id))) + .execute() + } + + override fun delete(id: UUID) { + val jBoard = Board.BOARD + this.dslContext.update(jBoard) + .set(jBoard.YN, 0) + .where(jBoard.ID.eq(UuidConverter.uuidToByteArray(id))) + .execute() + } + + override fun findAllByUserId( + userId: Long, + page: Int, + size: Int + ): List { + val jBoard = Board.BOARD + val data = this.dslContext.select( + jBoard.ID, + jBoard.TITLE, + jBoard.CREATED_AT + ) + .from(jBoard) + .where(jBoard.USER_ID.eq(userId).and(jBoard.YN.eq(1)).and(jBoard.ACTIVEYN.eq(1))) + .orderBy(jBoard.CREATED_AT.desc()) + .limit(size) + .offset(page * size) + .fetch() + + return data.map { + MyBoardDto.Companion.PageListRes( + id = UuidConverter.byteArrayToUUID(it.get("id", ByteArray::class.java)!!), + title = it.get("title", String::class.java)!!, + createdAt = it.get("created_at", LocalDateTime::class.java)!!, + ) + } + } + + override fun selectTotalCountByUserId(userId: Long): Long { + val jBoard = Board.BOARD + return this.dslContext + .selectCount() + .from(jBoard) + .where(jBoard.USER_ID.eq(userId).and(jBoard.YN.eq(1)).and(jBoard.ACTIVEYN.eq(1))) + .fetchOne(0, Long::class.java) ?: 0L + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/service/BoardService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/service/BoardService.kt new file mode 100644 index 0000000..15b3d16 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/service/BoardService.kt @@ -0,0 +1,51 @@ +package com.ddd.sonnypolabobe.domain.board.service + +import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardCreateRequest +import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardGetResponse +import com.ddd.sonnypolabobe.domain.board.repository.BoardJooqRepository +import com.ddd.sonnypolabobe.domain.polaroid.controller.dto.PolaroidGetResponse +import com.ddd.sonnypolabobe.global.exception.ApplicationException +import com.ddd.sonnypolabobe.global.exception.CustomErrorCode +import com.ddd.sonnypolabobe.global.util.S3Util +import com.ddd.sonnypolabobe.global.util.UuidConverter +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.* + +@Service +class BoardService( + private val boardJooqRepository: BoardJooqRepository, + private val s3Util: S3Util, + @Value("\${limit.count}") + private val limit: Int +) { + fun create(request: BoardCreateRequest): UUID = this.boardJooqRepository.insertOne(request)?.let { UuidConverter.byteArrayToUUID(it) } + ?: throw ApplicationException(CustomErrorCode.BOARD_CREATED_FAILED) + + fun getById(id: String): List { + return id.run { + val queryResult = boardJooqRepository.selectOneById(UuidConverter.stringToUUID(this@run)) + val groupByTitle = queryResult.groupBy { it.value1() } + groupByTitle.map { entry -> + val title = entry.key + val polaroids = entry.value.map { + PolaroidGetResponse( + id = it.value2() ?: 0L, + imageUrl = it.value3()?.let { it1 -> s3Util.getImgUrl(it1) } ?: "", + oneLineMessage = it.value4() ?: "폴라보와의 추억 한 줄", + userId = it.value6() ?: 0L + ) + }.filter { it.id != 0L } + BoardGetResponse(title = title ?: "", items = polaroids) + } + } + } + + fun getTotalCount(): Long = this.boardJooqRepository.selectTotalCount() + fun createAvailable(): Long { + return this.boardJooqRepository.selectTodayTotalCount().let { + if (it > limit) 0 else limit - it + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/file/controller/FileController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/file/controller/FileController.kt new file mode 100644 index 0000000..7ac2b1c --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/file/controller/FileController.kt @@ -0,0 +1,63 @@ +package com.ddd.sonnypolabobe.domain.file.controller + +import com.ddd.sonnypolabobe.domain.file.controller.dto.ImageResignedUrlResponse +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import com.ddd.sonnypolabobe.global.util.S3Util +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.web.bind.annotation.* +import java.io.File +import java.net.URL +import java.util.* + + +@RestController +@RequestMapping("/api/v1/file") +class FileController( + private val s3Util: S3Util +) { + + @GetMapping("/pre-signed-url") + @Operation( + summary = "S3에 이미지를 저장하기 위한 PreSignedUrl 을 반환합니다.", + description = """ + 1. 해당 API를 호출하면, 응답 데이터에 url과 imageKey를 반환합니다. + 2. Url은 2분간 유효합니다. + 3. ImageKey는 S3에 저장될 파일의 이름입니다. + 4. PUT 메소드로 Url에 이미지 파일을 binary로 보내면, s3에 업로드됩니다. + """ + ) + fun getPreSignedUrl( + @RequestParam(value = "fileKey") @Schema( + title = "유저의 보드 uuid", + defaultValue = "01906259-94b2-74ef-8c13-554385c42943", + example = "01906259-94b2-74ef-8c13-554385c42943" + ) fileKey: String, + ): ApplicationResponse { // fileName = env/fileKey/uuid + var fileName = UUID.randomUUID().toString() + fileName = (fileKey + File.separator) + fileName + val url: URL = this.s3Util.getPreSignedUrl(fileName) + val data = ImageResignedUrlResponse(fileName, url.toString()) + return ApplicationResponse.ok(data) + } + + @Operation( + summary = "S3 이미지 삭제", description = """ + S3에 저장된 이미지를 삭제합니다. +""" + ) + @DeleteMapping("/uploaded-image") + fun deleteImage( + @RequestParam(value = "imageKey") imageKey: String + ): ApplicationResponse { + this.s3Util.deleteImage(imageKey) + return ApplicationResponse.ok() + } + + @Operation( + summary = "S3 이미지 접근 주소" + ) + @GetMapping("image-url") + fun getImageUrl(@RequestParam(value = "imageKey") imageKey: String) = + ApplicationResponse.ok(this.s3Util.getImgUrl(imageKey)) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/file/controller/dto/ImageResignedUrlResponse.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/file/controller/dto/ImageResignedUrlResponse.kt new file mode 100644 index 0000000..5ddac9f --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/file/controller/dto/ImageResignedUrlResponse.kt @@ -0,0 +1,6 @@ +package com.ddd.sonnypolabobe.domain.file.controller.dto + +data class ImageResignedUrlResponse( + val imageKey : String, + val url : String +) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/controller/OauthController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/controller/OauthController.kt new file mode 100644 index 0000000..4275be1 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/controller/OauthController.kt @@ -0,0 +1,43 @@ +package com.ddd.sonnypolabobe.domain.oauth.controller + +import com.ddd.sonnypolabobe.domain.oauth.service.OauthService +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import io.swagger.v3.oas.annotations.Operation +import jakarta.validation.Valid +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/oauth") +class OauthController(private val oauthService: OauthService) { + + @Operation(summary = "회원가입/로그인", description = """ + 회원가입/로그인을 진행합니다. + 이미 가입된 회원이라면 로그인을 진행하고, 가입되지 않은 회원이라면 회원가입을 진행합니다. + + 요청 바디의 값이 변경되었습니다. - 2024.08.11 + + - birthDt, gender 필드는 프로필 수정에서 업데이트 가능합니다. + 응답 DTO에 기본값 바인딩으로 채웠습니다. + """) + @PostMapping("/sign-in") + fun signIn(@RequestBody @Valid request: UserDto.Companion.CreateReq) + = ApplicationResponse.ok(this.oauthService.signIn(request)) + + @PutMapping("/re-issue") + fun reIssue( + @RequestHeader(name = "Authorization", required = true) header: String + ) = ApplicationResponse.ok(this.oauthService.reIssue(header)) + + @Operation(summary = "로그아웃", description = """ + 로그아웃을 진행합니다. + 액세스 토큰을 헤더에 담아주세요. + """) + @PostMapping("/sign-out") + fun signOut() : ApplicationResponse { + val userId = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + this.oauthService.signOut(userId.id) + return ApplicationResponse.ok() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/service/OauthService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/service/OauthService.kt new file mode 100644 index 0000000..dc6ed19 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/service/OauthService.kt @@ -0,0 +1,104 @@ +package com.ddd.sonnypolabobe.domain.oauth.service + +import com.ddd.sonnypolabobe.domain.user.dto.GenderType +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.domain.user.repository.UserJooqRepository +import com.ddd.sonnypolabobe.domain.user.token.dto.UserTokenDto +import com.ddd.sonnypolabobe.domain.user.token.repository.UserTokenJooqRepository +import com.ddd.sonnypolabobe.global.security.JwtUtil +import com.ddd.sonnypolabobe.global.util.DateConverter.dateToLocalDateTime +import com.ddd.sonnypolabobe.logger +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + + +@Service +class OauthService( + private val userRepository : UserJooqRepository, + private val jwtUtil: JwtUtil, + private val userTokenRepository: UserTokenJooqRepository + ) { + + @Transactional + fun signIn(request: UserDto.Companion.CreateReq): UserDto.Companion.TokenRes { + this.userRepository.findByEmail(request.email)?.let { + val tokenRequest = UserDto.Companion.CreateTokenReq( + id = it.id, + email = it.email, + nickName = it.nickName + ) + + val tokenRes = this.jwtUtil.generateAccessToken(tokenRequest) + + val userToken = UserTokenDto( + userId = it.id, + accessToken = tokenRes.accessToken, + expiredAt = dateToLocalDateTime(tokenRes.expiredDate), + refreshToken = tokenRes.refreshToken + ) + + this.userTokenRepository.updateByUserId(userToken) + return tokenRes.also { _ -> + tokenRes.isNewUser = false + tokenRes.birthDt = it.birthDt + tokenRes.gender = it.gender + } + } ?: run { + val userId = this.userRepository.insertOne(request) + + // 토큰 생성 + val tokenRequest = UserDto.Companion.CreateTokenReq( + id = userId, + email = request.email, + nickName = request.nickName + ) + + val tokenRes = this.jwtUtil.generateAccessToken(tokenRequest) + + val userToken = UserTokenDto( + userId = userId, + accessToken = tokenRes.accessToken, + expiredAt = dateToLocalDateTime(tokenRes.expiredDate), + refreshToken = tokenRes.refreshToken + ) + + this.userTokenRepository.insertOne(userToken) + return tokenRes.also { _ -> + tokenRes.isNewUser = true + tokenRes.birthDt = null + tokenRes.gender = GenderType.NONE + } + } + } + + fun reIssue(token: String?): UserDto.Companion.TokenRes{ + val tokenFromDB = token?.let { + val slicedToken = if(it.startsWith("Bearer ")) it.substring(7) else it + this.jwtUtil.getAuthenticatedMemberFromRefreshToken(slicedToken) + } ?: throw RuntimeException("Token Not Found") + val user = this.userRepository.findById(tokenFromDB.id.toLong()) ?: throw RuntimeException("User Not Found") + + // 토큰 생성 + val tokenRequest = UserDto.Companion.CreateTokenReq( + id = user.id, + email = user.email, + nickName = user.nickName + ) + + val tokenRes = this.jwtUtil.generateAccessToken(tokenRequest) + + val userToken = UserTokenDto( + userId = user.id, + accessToken = tokenRes.accessToken, + expiredAt = dateToLocalDateTime(tokenRes.expiredDate), + refreshToken = tokenRes.refreshToken + ) + + this.userTokenRepository.updateByUserId(userToken) + return tokenRes + } + + fun signOut(id: Long) { + this.userTokenRepository.deleteByUserId(id) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/BoardPolaroidController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/BoardPolaroidController.kt new file mode 100644 index 0000000..399a249 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/BoardPolaroidController.kt @@ -0,0 +1,21 @@ +package com.ddd.sonnypolabobe.domain.polaroid.controller + +import com.ddd.sonnypolabobe.domain.polaroid.controller.dto.PolaroidCreateRequest +import com.ddd.sonnypolabobe.domain.polaroid.service.PolaroidService +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import io.swagger.v3.oas.annotations.Operation +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/boards/{boardId}/polaroids") +class BoardPolaroidController(private val polaroidService: PolaroidService) { + + @Operation(summary = "폴라로이드 생성", description = """ + 폴라로이드를 생성합니다. + """) + @PostMapping + fun create(@PathVariable boardId : String, @RequestBody @Valid request : PolaroidCreateRequest) + = ApplicationResponse.ok(this.polaroidService.create(boardId, request)) + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/PolaroidController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/PolaroidController.kt new file mode 100644 index 0000000..35ae562 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/PolaroidController.kt @@ -0,0 +1,20 @@ +package com.ddd.sonnypolabobe.domain.polaroid.controller + +import com.ddd.sonnypolabobe.domain.polaroid.service.PolaroidService +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/polaroids") +class PolaroidController(private val polaroidService: PolaroidService) { + + @Operation(summary = "폴라로이드 조회", description = """ + 폴라로이드를 조회합니다. + """) + @GetMapping("/{id}") + fun getById(@PathVariable id: Long) = ApplicationResponse.ok(this.polaroidService.getById(id)) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidCreateRequest.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidCreateRequest.kt new file mode 100644 index 0000000..05bed06 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidCreateRequest.kt @@ -0,0 +1,13 @@ +package com.ddd.sonnypolabobe.domain.polaroid.controller.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size + +@Schema(description = "폴라로이드 생성 요청") +data class PolaroidCreateRequest( + @Schema(description = "이미지 키", example = "imageKey") + val imageKey : String, + @Schema(description = "한 줄 문구", example = "한 줄 메시지입니다. 최대 20자까지 가능합니다.") + @field:Size(max = 20) + val oneLineMessage : String +) \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidGetResponse.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidGetResponse.kt new file mode 100644 index 0000000..092882c --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidGetResponse.kt @@ -0,0 +1,15 @@ +package com.ddd.sonnypolabobe.domain.polaroid.controller.dto + +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +data class PolaroidGetResponse( + @Schema(description = "폴라로이드 ID", example = "1") + val id: Long, + @Schema(description = "이미지 주소", example = "https://image.com/image.jpg") + val imageUrl: String, + @Schema(description = "한 줄 문구", example = "한 줄 메시지입니다.") + val oneLineMessage: String, + @Schema(description = "작성자 ID", example = "userId") + val userId: Long?, +) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/repository/PolaroidJooqRepository.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/repository/PolaroidJooqRepository.kt new file mode 100644 index 0000000..61345b9 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/repository/PolaroidJooqRepository.kt @@ -0,0 +1,10 @@ +package com.ddd.sonnypolabobe.domain.polaroid.repository + +import com.ddd.sonnypolabobe.domain.polaroid.controller.dto.PolaroidCreateRequest +import com.ddd.sonnypolabobe.jooq.polabo.tables.records.PolaroidRecord + +interface PolaroidJooqRepository { + fun insertOne(boardId: ByteArray, request: PolaroidCreateRequest): Long + fun selectOneById(id: Long): PolaroidRecord + fun countByBoardId(uuidToByteArray: ByteArray): Int +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/repository/PolaroidJooqRepositoryImpl.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/repository/PolaroidJooqRepositoryImpl.kt new file mode 100644 index 0000000..a775a83 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/repository/PolaroidJooqRepositoryImpl.kt @@ -0,0 +1,56 @@ +package com.ddd.sonnypolabobe.domain.polaroid.repository + +import com.ddd.sonnypolabobe.domain.polaroid.controller.dto.PolaroidCreateRequest +import com.ddd.sonnypolabobe.global.exception.ApplicationException +import com.ddd.sonnypolabobe.global.exception.CustomErrorCode +import com.ddd.sonnypolabobe.global.util.DateConverter +import com.ddd.sonnypolabobe.jooq.polabo.tables.Polaroid +import com.ddd.sonnypolabobe.jooq.polabo.tables.records.PolaroidRecord +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class PolaroidJooqRepositoryImpl(private val dslContext: DSLContext) : PolaroidJooqRepository { + override fun insertOne(boardId: ByteArray, request: PolaroidCreateRequest): Long { + val jPolaroid = Polaroid.POLAROID + val insertValue = jPolaroid.newRecord().apply { + this.boardId = boardId + this.imageKey = request.imageKey + this.oneLineMessage = request.oneLineMessage + this.createdAt = DateConverter.convertToKst(LocalDateTime.now()) + this.yn = 1 + this.activeyn = 1 + } + return this.dslContext.insertInto(jPolaroid) + .set(insertValue) + .returningResult(jPolaroid.ID) + .fetchOne()?.value1() ?: 0 + } + + override fun selectOneById(id: Long): PolaroidRecord { + val jPolaroid = Polaroid.POLAROID + return this.dslContext + .selectFrom(jPolaroid) + .where( + jPolaroid.ID.eq(id) + .and(jPolaroid.YN.eq(1)) + .and(jPolaroid.ACTIVEYN.eq(1)) + ) + .fetchOne()?.original() + ?: throw ApplicationException(CustomErrorCode.POLAROID_NOT_FOUND) + } + + override fun countByBoardId(uuidToByteArray: ByteArray): Int { + val jPolaroid = Polaroid.POLAROID + return this.dslContext + .selectCount() + .from(jPolaroid) + .where( + jPolaroid.BOARD_ID.eq(uuidToByteArray) + .and(jPolaroid.YN.eq(1)) + .and(jPolaroid.ACTIVEYN.eq(1)) + ) + .fetchOne(0, Int::class.java) ?: 0 + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/service/PolaroidService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/service/PolaroidService.kt new file mode 100644 index 0000000..01e540b --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/service/PolaroidService.kt @@ -0,0 +1,32 @@ +package com.ddd.sonnypolabobe.domain.polaroid.service + +import com.ddd.sonnypolabobe.domain.polaroid.controller.dto.PolaroidCreateRequest +import com.ddd.sonnypolabobe.domain.polaroid.controller.dto.PolaroidGetResponse +import com.ddd.sonnypolabobe.domain.polaroid.repository.PolaroidJooqRepository +import com.ddd.sonnypolabobe.global.exception.ApplicationException +import com.ddd.sonnypolabobe.global.exception.CustomErrorCode +import com.ddd.sonnypolabobe.global.util.S3Util +import com.ddd.sonnypolabobe.global.util.UuidConverter +import org.springframework.stereotype.Service + +@Service +class PolaroidService(private val polaroidJooqRepository: PolaroidJooqRepository, private val s3Util: S3Util){ + fun create(boardId: String, request: PolaroidCreateRequest): Long { + val boardIdUuid = UuidConverter.stringToUUID(boardId) + val countByBoardId = this.polaroidJooqRepository.countByBoardId(UuidConverter.uuidToByteArray(boardIdUuid)) + if(countByBoardId > 50) throw ApplicationException(CustomErrorCode.POLAROID_COUNT_EXCEEDED) + return this.polaroidJooqRepository.insertOne(UuidConverter.uuidToByteArray(boardIdUuid), request) + } + + fun getById(id: Long): PolaroidGetResponse { + return this.polaroidJooqRepository.selectOneById(id).let { + PolaroidGetResponse( + id = it.id!!, + imageUrl = s3Util.getImgUrl(it.imageKey!!), + oneLineMessage = it.oneLineMessage ?: "", + userId = it.userId + ) + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/controller/UserController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/controller/UserController.kt new file mode 100644 index 0000000..e65d85f --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/controller/UserController.kt @@ -0,0 +1,61 @@ +package com.ddd.sonnypolabobe.domain.user.controller + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.domain.user.service.UserService +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import com.ddd.sonnypolabobe.global.util.DateConverter +import io.swagger.v3.oas.annotations.Operation +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.* +import java.time.LocalDateTime + +@RestController +@RequestMapping("/api/v1/user") +class UserController( + private val userService: UserService +) { + + @Operation(summary = "프로필 변경", description = """ + 프로필 사항을 변경합니다. + + 유저가 가진 정보 중 변경한 값 + 변경하지 않은 값 모두 보내주세요. + 보내는 값을 그대로 디비에 저장합니다. + """) + @PutMapping("/profile") + fun updateProfile(@RequestBody request: UserDto.Companion.UpdateReq) + : ApplicationResponse { + val userInfoFromToken = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + this.userService.updateProfile(request, userInfoFromToken.id) + return ApplicationResponse.ok() + } + + @Operation(summary = "프로필 조회", description = """ + 프로필을 조회합니다. + """) + @GetMapping("/profile") + fun getProfile() : ApplicationResponse { + val userInfoFromToken = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + return ApplicationResponse.ok(this.userService.findById(userInfoFromToken.id)) + } + + @Operation(summary = "회원 탈퇴", description = """ + 회원 탈퇴를 진행합니다. + 탈퇴 사유를 입력해주세요. + 사유가 '기타'인 경우에만 reason 필드를 채워주세요. + """) + @PutMapping("/withdraw") + fun withdraw(@RequestBody request: UserDto.Companion.WithdrawReq) : ApplicationResponse { + val userInfoFromToken = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + this.userService.withdraw(request, userInfoFromToken.id) + return ApplicationResponse.ok() + } + + @Operation(summary = "회원 계정 존재 여부 확인", description = """ + 이메일로 계정 등록 여부를 확인합니다. + """) + @GetMapping("/check-exist") + fun checkExist( + @RequestParam("email") email: String + ) = ApplicationResponse.ok(this.userService.checkExist(email)) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/GenderType.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/GenderType.kt new file mode 100644 index 0000000..747afa4 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/GenderType.kt @@ -0,0 +1,7 @@ +package com.ddd.sonnypolabobe.domain.user.dto + +enum class GenderType(val description: String) { + F("여성"), + M("남성"), + NONE("없음") +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/UserDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/UserDto.kt new file mode 100644 index 0000000..7e626af --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/UserDto.kt @@ -0,0 +1,102 @@ +package com.ddd.sonnypolabobe.domain.user.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.Email +import org.intellij.lang.annotations.RegExp +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.Date +import java.util.stream.Collectors + +class UserDto { + companion object { + data class CreateReq( + @field:Email + val email: String, + val nickName: String, +// val birthDt : LocalDate?, +// val gender : GenderType? + ) + + data class UpdateReq( + @JsonProperty("nickName") + val nickName: String, + @JsonProperty("birthDt") + val birthDt: LocalDate?, + val gender: GenderType? + ) + + data class CreateTokenReq( + val id: Long, + val email: String, + val nickName: String + ) + + data class TokenRes( + val accessToken: String, + val refreshToken: String, + val expiredDate: Date, + var isNewUser: Boolean, + val nickName: String, + var birthDt: LocalDate?, + var gender: GenderType + ) { + constructor( + accessToken: String, + refreshToken: String, + expiredDate: Date, + isNewUser: Boolean, + nickName: String + ) : this( + accessToken, + refreshToken, + expiredDate, + isNewUser, + nickName, + null, + GenderType.NONE + ) + } + + data class ProfileRes( + val id: Long, + val email: String, + val nickName: String, + val createdAt: LocalDateTime, + ) + + data class WithdrawReq( + val type: WithdrawType, + val reason: String? + ) + + data class Res( + val id: Long, + val email: String, + val nickName: String, + val yn: Boolean, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime?, + val birthDt: LocalDate?, + val gender : GenderType + ) : UserDetails { + override fun getAuthorities(): MutableCollection { + val roles = mutableListOf("ROLE_USER") + return roles.stream() + .map { role -> SimpleGrantedAuthority(role) } + .collect(Collectors.toList()) + } + + override fun getPassword(): String { + return "" + } + + override fun getUsername(): String { + return id.toString() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/WithdrawType.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/WithdrawType.kt new file mode 100644 index 0000000..2dbbeca --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/WithdrawType.kt @@ -0,0 +1,9 @@ +package com.ddd.sonnypolabobe.domain.user.dto + +enum class WithdrawType(val description : String) { + NOT_USE("더이상 사용하지 않아요"), + WORRY_ABOUT_PERSONAL_INFO("개인정보 우려"), + DROP_MY_DATA("내 데이터 삭제"), + WANT_TO_NEW_ACCOUNT("새로운 계정을 만들고 싶어요"), + OTHER("기타") +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepository.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepository.kt new file mode 100644 index 0000000..969ae7f --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepository.kt @@ -0,0 +1,12 @@ +package com.ddd.sonnypolabobe.domain.user.repository + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto + +interface UserJooqRepository { + fun insertOne(request: UserDto.Companion.CreateReq): Long + + fun findById(id: Long): UserDto.Companion.Res? + fun findByEmail(email: String): UserDto.Companion.Res? + fun updateProfile(request: UserDto.Companion.UpdateReq, userId: Long) + fun deleteById(id: Long) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepositoryImpl.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepositoryImpl.kt new file mode 100644 index 0000000..91816a9 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepositoryImpl.kt @@ -0,0 +1,94 @@ +package com.ddd.sonnypolabobe.domain.user.repository + +import com.ddd.sonnypolabobe.domain.user.dto.GenderType +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.util.DateConverter +import com.ddd.sonnypolabobe.jooq.polabo.enums.UserGender +import com.ddd.sonnypolabobe.jooq.polabo.tables.User +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class UserJooqRepositoryImpl(private val dslContext: DSLContext) : UserJooqRepository{ + override fun insertOne(request: UserDto.Companion.CreateReq): Long { + val jUser = User.USER + val result = this.dslContext.insertInto(jUser, + jUser.EMAIL, + jUser.NICK_NAME, + jUser.CREATED_AT, + jUser.YN, + ) + .values( + request.email, + request.nickName, + DateConverter.convertToKst(LocalDateTime.now()), + 1, + ).execute() + if(result == 0) throw Exception("Failed to insert user") + + return this.dslContext.select(jUser.ID) + .from(jUser) + .where(jUser.EMAIL.eq(request.email).and(jUser.YN.eq(1))) + .fetchOneInto(Long::class.java) ?: throw Exception("Failed to get user id") + } + + override fun findById(id: Long): UserDto.Companion.Res? { + val jUser = User.USER + val record = this.dslContext.selectFrom(jUser) + .where(jUser.ID.eq(id)) + .fetchOne() + + return record?.let { + UserDto.Companion.Res( + id = it.id!!, + email = it.email!!, + nickName = it.nickName!!, + yn = it.yn?.toInt() == 1, + createdAt = it.createdAt!!, + updatedAt = it.updatedAt, + birthDt = it.birthDt, + gender = GenderType.valueOf(it.gender?.name ?: GenderType.NONE.name) + ) + } + } + + override fun findByEmail(email: String): UserDto.Companion.Res? { + val jUser = User.USER + val record = this.dslContext.selectFrom(jUser) + .where(jUser.EMAIL.eq(email).and(jUser.YN.eq(1))) + .fetchOne() + + return record?.let { + UserDto.Companion.Res( + id = it.id!!, + email = it.email!!, + nickName = it.nickName!!, + yn = it.yn?.toInt() == 1, + createdAt = it.createdAt!!, + updatedAt = it.updatedAt, + birthDt = it.birthDt, + gender = GenderType.valueOf(it.gender?.name ?: GenderType.NONE.name) + ) + } + } + + override fun updateProfile(request: UserDto.Companion.UpdateReq, userId: Long) { + val jUser = User.USER + this.dslContext.update(jUser) + .set(jUser.NICK_NAME, request.nickName) + .set(jUser.BIRTH_DT, request.birthDt) + .set(jUser.GENDER, UserGender.valueOf(request.gender?.name ?: UserGender.NONE.name)) + .set(jUser.UPDATED_AT, DateConverter.convertToKst(LocalDateTime.now())) + .where(jUser.ID.eq(userId)) + .execute() + } + + override fun deleteById(id: Long) { + val jUser = User.USER + this.dslContext.update(jUser) + .set(jUser.YN, 0) + .where(jUser.ID.eq(id)) + .execute() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepository.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepository.kt new file mode 100644 index 0000000..ec9b0be --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepository.kt @@ -0,0 +1,7 @@ +package com.ddd.sonnypolabobe.domain.user.repository + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto + +interface WithdrawJooqRepository { + fun insertOne(request: UserDto.Companion.WithdrawReq, userId: Long) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepositoryImpl.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepositoryImpl.kt new file mode 100644 index 0000000..a5c50d4 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepositoryImpl.kt @@ -0,0 +1,30 @@ +package com.ddd.sonnypolabobe.domain.user.repository + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.util.DateConverter +import com.ddd.sonnypolabobe.jooq.polabo.enums.WithdrawType +import com.ddd.sonnypolabobe.jooq.polabo.tables.Withdraw +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class WithdrawJooqRepositoryImpl(private val dslContext: DSLContext): WithdrawJooqRepository { + override fun insertOne(request: UserDto.Companion.WithdrawReq, userId: Long) { + val jWithdraw = Withdraw.WITHDRAW + + this.dslContext.insertInto(jWithdraw, + jWithdraw.USER_ID, + jWithdraw.TYPE, + jWithdraw.REASON, + jWithdraw.CREATED_AT + ) + .values( + userId, + WithdrawType.valueOf(request.type.name), + request.reason, + DateConverter.convertToKst(LocalDateTime.now()) + ) + .execute() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/service/UserService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/service/UserService.kt new file mode 100644 index 0000000..de96dc5 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/service/UserService.kt @@ -0,0 +1,38 @@ +package com.ddd.sonnypolabobe.domain.user.service + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.domain.user.repository.UserJooqRepository +import com.ddd.sonnypolabobe.domain.user.repository.WithdrawJooqRepository +import org.springframework.stereotype.Service + +@Service +class UserService( + private val userJooqRepository: UserJooqRepository, + private val withdrawJooqRepository: WithdrawJooqRepository +) { + fun updateProfile(request: UserDto.Companion.UpdateReq, userId: Long) { + this.userJooqRepository.updateProfile(request, userId) + } + + fun findById(id: Long): UserDto.Companion.ProfileRes { + return this.userJooqRepository.findById(id).let { + UserDto.Companion.ProfileRes( + id = it!!.id, + nickName = it.nickName, + email = it.email, + createdAt = it.createdAt + ) + } + } + + fun withdraw(request: UserDto.Companion.WithdrawReq, id: Long) { + this.withdrawJooqRepository.insertOne(request, id) + this.userJooqRepository.deleteById(id) + } + + fun checkExist(email: String): Boolean { + return this.userJooqRepository.findByEmail(email) != null + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/dto/UserTokenDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/dto/UserTokenDto.kt new file mode 100644 index 0000000..0067e0f --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/dto/UserTokenDto.kt @@ -0,0 +1,10 @@ +package com.ddd.sonnypolabobe.domain.user.token.dto + +import java.time.LocalDateTime + +data class UserTokenDto( + val userId: Long, + val accessToken: String, + val expiredAt: LocalDateTime, + val refreshToken: String +) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepository.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepository.kt new file mode 100644 index 0000000..7fef16b --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepository.kt @@ -0,0 +1,10 @@ +package com.ddd.sonnypolabobe.domain.user.token.repository + +import com.ddd.sonnypolabobe.domain.user.token.dto.UserTokenDto + +interface UserTokenJooqRepository { + fun insertOne(userToken: UserTokenDto) + fun findByRefreshToken(token: String): UserTokenDto? + fun updateByUserId(userToken: UserTokenDto) + fun deleteByUserId(userId: Long) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepositoryImpl.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepositoryImpl.kt new file mode 100644 index 0000000..425b41e --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepositoryImpl.kt @@ -0,0 +1,74 @@ +package com.ddd.sonnypolabobe.domain.user.token.repository + +import com.ddd.sonnypolabobe.domain.user.token.dto.UserTokenDto +import com.ddd.sonnypolabobe.global.util.DateConverter +import com.ddd.sonnypolabobe.jooq.polabo.tables.UserToken +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class UserTokenJooqRepositoryImpl(private val dslContext: DSLContext) : UserTokenJooqRepository { + override fun insertOne(userToken: UserTokenDto) { + val jUserToken = UserToken.USER_TOKEN + val insertValue = jUserToken.newRecord().apply { + this.userId = userToken.userId + this.accessToken = userToken.accessToken + this.accessExpiredAt = userToken.expiredAt + this.createdAt = DateConverter.convertToKst(LocalDateTime.now()) + this.updatedAt = DateConverter.convertToKst(LocalDateTime.now()) + this.refreshToken = userToken.refreshToken + } + this.dslContext.insertInto(jUserToken, + jUserToken.USER_ID, + jUserToken.ACCESS_TOKEN, + jUserToken.ACCESS_EXPIRED_AT, + jUserToken.CREATED_AT, + jUserToken.UPDATED_AT, + jUserToken.REFRESH_TOKEN + ) + .values( + insertValue.userId, + insertValue.accessToken, + insertValue.accessExpiredAt, + insertValue.createdAt, + insertValue.updatedAt, + insertValue.refreshToken + ) + .onDuplicateKeyUpdate() + .set(jUserToken.USER_ID, insertValue.userId) + .execute() + } + + override fun findByRefreshToken(token: String): UserTokenDto? { + val jUserToken = UserToken.USER_TOKEN + return this.dslContext.selectFrom(jUserToken) + .where(jUserToken.REFRESH_TOKEN.eq(token)) + .fetchOne()?.map { + UserTokenDto( + userId = it.get(jUserToken.USER_ID)!!, + accessToken = it.get(jUserToken.ACCESS_TOKEN)!!, + expiredAt = it.get(jUserToken.ACCESS_EXPIRED_AT)!!, + refreshToken = it.get(jUserToken.REFRESH_TOKEN)!! + ) + } + } + + override fun updateByUserId(userToken: UserTokenDto) { + val jUserToken = UserToken.USER_TOKEN + this.dslContext.update(jUserToken) + .set(jUserToken.ACCESS_TOKEN, userToken.accessToken) + .set(jUserToken.ACCESS_EXPIRED_AT, userToken.expiredAt) + .set(jUserToken.UPDATED_AT, DateConverter.convertToKst(LocalDateTime.now())) + .set(jUserToken.REFRESH_TOKEN, userToken.refreshToken) + .where(jUserToken.USER_ID.eq(userToken.userId)) + .execute() + } + + override fun deleteByUserId(userId: Long) { + val jUserToken = UserToken.USER_TOKEN + this.dslContext.deleteFrom(jUserToken) + .where(jUserToken.USER_ID.eq(userId)) + .execute() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/config/JasyptConfig.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/JasyptConfig.kt new file mode 100644 index 0000000..1f0dbce --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/JasyptConfig.kt @@ -0,0 +1,26 @@ +package com.ddd.sonnypolabobe.global.config + +import org.jasypt.encryption.StringEncryptor +import org.jasypt.encryption.pbe.PooledPBEStringEncryptor +import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JasyptConfig { + + @Bean("jasyptStringEncryptor") + fun stringEncryptor(): StringEncryptor { + val encryptor = PooledPBEStringEncryptor() + val config = SimpleStringPBEConfig() + config.password = System.getenv("JASYPT_ENCRYPTOR_PASSWORD") + config.algorithm = "PBEWithMD5AndDES" + config.setKeyObtentionIterations("1000") + config.setPoolSize("1") + config.stringOutputType = "base64" + config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator") + config.setIvGeneratorClassName("org.jasypt.iv.NoIvGenerator") + encryptor.setConfig(config) + return encryptor + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SecurityConfig.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SecurityConfig.kt new file mode 100644 index 0000000..589d7c5 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SecurityConfig.kt @@ -0,0 +1,90 @@ +package com.ddd.sonnypolabobe.global.config + +import com.ddd.sonnypolabobe.global.security.JwtAuthenticationFilter +import com.ddd.sonnypolabobe.global.security.JwtExceptionFilter +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.security.web.util.matcher.RequestMatcher +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource + +@Configuration +@EnableMethodSecurity +class SecurityConfig( + private val jwtAuthenticationFilter: JwtAuthenticationFilter, + private val jwtExceptionFilter: JwtExceptionFilter +) { + companion object { + val ALLOW_URLS = listOf( + "/api/v1/boards/{id}", + "/api/v1/boards/create-available", + "/api/v1/boards/total-count", + "/api/v1/file/**", + "/api/v1/oauth/sign-in", + "/api/v1/oauth/re-issue", + "/api/v1/user/check-exist", + "/health", + "/swagger-ui/**", + "/v3/api-docs/**", + "/api/v1/polaroids/{id}", + "/api/v1/boards/{boardId}/polaroids" + ) + } + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + return http + .cors {} + .csrf{ + it.disable() + } + .httpBasic { + it.disable() + } + .formLogin { it.disable() } + .sessionManagement { sessionManagementConfig -> + sessionManagementConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) + .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter::class.java) + .authorizeHttpRequests { + it.requestMatchers(RequestMatcher { request -> + ALLOW_URLS.any { url -> AntPathRequestMatcher(url).matches(request) } + }).permitAll() + it.anyRequest().authenticated() + } + .build() + } + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + + val configuration = CorsConfiguration().apply { + allowCredentials = true + allowedOrigins = listOf("http://localhost:3000", "https://polabo.site", + "http://polabo.site", "http://dev.polabo.site", "https://dev.polabo.site") + allowedMethods = listOf( + HttpMethod.POST.name(), + HttpMethod.GET.name(), + HttpMethod.PUT.name(), + HttpMethod.DELETE.name(), + HttpMethod.OPTIONS.name() + ) + allowedHeaders = listOf("*") + } + + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", configuration) + + return source + + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SwaggerConfig.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SwaggerConfig.kt new file mode 100644 index 0000000..8b1147d --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SwaggerConfig.kt @@ -0,0 +1,34 @@ +package com.ddd.sonnypolabobe.global.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + + +@Configuration +class SwaggerConfig { + + @Bean + fun openAPI(): OpenAPI { + val securityScheme: SecurityScheme = getSecurityScheme() + val securityRequirement: SecurityRequirement = getSecurityRequireMent() + + return OpenAPI() + .addServersItem(Server().url("/")) + .components(Components().addSecuritySchemes("bearerAuth", securityScheme)) + .security(listOf(securityRequirement)) + } + + private fun getSecurityScheme(): SecurityScheme { + return SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("Bearer").bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER).name("Authorization") + } + + private fun getSecurityRequireMent(): SecurityRequirement { + return SecurityRequirement().addList("bearerAuth") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/config/WebConfig.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/WebConfig.kt new file mode 100644 index 0000000..86f8df7 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/WebConfig.kt @@ -0,0 +1,16 @@ +package com.ddd.sonnypolabobe.global.config + +import com.ddd.sonnypolabobe.global.security.RateLimitingInterceptor +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebConfig(private val rateLimitingInterceptor: RateLimitingInterceptor) : WebMvcConfigurer { + + override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor(rateLimitingInterceptor) + .addPathPatterns("/api/v1/boards") + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/entity/BaseEntity.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/entity/BaseEntity.kt new file mode 100644 index 0000000..08c6aa3 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/entity/BaseEntity.kt @@ -0,0 +1,12 @@ +package com.ddd.sonnypolabobe.global.entity + +import java.time.LocalDateTime +import java.util.UUID + +interface BaseEntity { + val id : UUID + val yn : Boolean + val createdAt : LocalDateTime + val updatedAt : LocalDateTime + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/entity/PageDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/entity/PageDto.kt new file mode 100644 index 0000000..7be6e65 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/entity/PageDto.kt @@ -0,0 +1,23 @@ +package com.ddd.sonnypolabobe.global.entity + + +data class PageDto( + val data: List, + val totalCount: Long, + var totalPage: Int, + val currentPage: Int, + val size : Int +) { + + constructor( + data: List, + totalCount: Long, + page: Int, + size: Int + ) : this(data, totalCount, 0, page, size + ) + init { + totalPage = if(totalCount % size == 0L) (totalCount / size).toInt() + else (totalCount / size + 1).toInt() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/ApplicationException.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/ApplicationException.kt new file mode 100644 index 0000000..4131ab8 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/ApplicationException.kt @@ -0,0 +1,5 @@ +package com.ddd.sonnypolabobe.global.exception + +data class ApplicationException( + val error : CustomErrorCode +) :RuntimeException(error.message) \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/CustomErrorCode.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/CustomErrorCode.kt new file mode 100644 index 0000000..6a519d7 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/CustomErrorCode.kt @@ -0,0 +1,29 @@ +package com.ddd.sonnypolabobe.global.exception + +import org.springframework.http.HttpStatus + +enum class CustomErrorCode( + val status: HttpStatus, + val code: String, + val message: String +) { + INVALID_VALUE_EXCEPTION(HttpStatus.BAD_REQUEST, "COM001", "잘못된 값입니다."), + INTERNAL_SERVER_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "COM002", "서버 내부 오류입니다."), + + + POLAROID_NOT_FOUND(HttpStatus.NOT_FOUND, "POL001", "폴라로이드를 찾을 수 없습니다."), + POLAROID_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "POL002", "보드당 폴라로이드는 50개까지만 생성 가능합니다."), + + // jwt + JWT_INVALID(HttpStatus.UNAUTHORIZED, "JWT001", "유효하지 않은 토큰입니다."), + JWT_EXPIRED(HttpStatus.UNAUTHORIZED, "JWT002", "만료된 토큰입니다."), + JWT_MALFORMED(HttpStatus.UNAUTHORIZED, "JWT003", "잘못된 토큰입니다."), + JWT_UNSUPPORTED(HttpStatus.UNAUTHORIZED, "JWT004", "지원되지 않는 토큰입니다."), + JWT_ILLEGAL_ARGUMENT(HttpStatus.UNAUTHORIZED, "JWT005", "잘못된 인자입니다."), + JWT_SIGNATURE(HttpStatus.UNAUTHORIZED, "JWT006", "잘못된 서명입니다."), + + // board + BOARD_CREATED_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "BOARD001", "보드 생성에 실패했습니다."), + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "BOARD002", "보드를 찾을 수 없습니다."), + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..d90670f --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/GlobalExceptionHandler.kt @@ -0,0 +1,49 @@ +package com.ddd.sonnypolabobe.global.exception + +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import com.ddd.sonnypolabobe.global.util.DiscordApiClient +import com.ddd.sonnypolabobe.logger +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler( + private val discordApiClient: DiscordApiClient +) { + @ExceptionHandler(ApplicationException::class) + fun applicationException(ex: ApplicationException): ResponseEntity> { + logger().error("error : ${ex.error}") + if(ex.error.status.is5xxServerError) { + this.discordApiClient.sendErrorTrace( + ex.error.status.toString(), ex.error.message, + ex.stackTrace.contentToString() + ) + } + return ResponseEntity.status(ex.error.status).body(ApplicationResponse.error(ex.error)) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun validationException(ex: MethodArgumentNotValidException): ResponseEntity> { + logger().error("error : ${ex.bindingResult.allErrors[0].defaultMessage}") + return ResponseEntity.status(CustomErrorCode.INVALID_VALUE_EXCEPTION.status) + .body( + ApplicationResponse.error( + CustomErrorCode.INVALID_VALUE_EXCEPTION, + ex.bindingResult.allErrors[0].defaultMessage!! + ) + ) + } + + @ExceptionHandler(RuntimeException::class) + fun runtimeException(ex: RuntimeException): ResponseEntity> { + logger().error("error : ${ex.message}") + this.discordApiClient.sendErrorTrace( + "500", ex.message, + ex.stackTrace.contentToString() + ) + return ResponseEntity.status(CustomErrorCode.INTERNAL_SERVER_EXCEPTION.status) + .body(ApplicationResponse.error(CustomErrorCode.INTERNAL_SERVER_EXCEPTION)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/response/ApplicationResponse.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/response/ApplicationResponse.kt new file mode 100644 index 0000000..275712e --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/response/ApplicationResponse.kt @@ -0,0 +1,45 @@ +package com.ddd.sonnypolabobe.global.response + +import com.ddd.sonnypolabobe.global.exception.CustomErrorCode +import com.fasterxml.jackson.annotation.JsonInclude +import jakarta.annotation.Generated +import java.time.LocalDateTime + +data class ApplicationResponse( + val localDateTime: LocalDateTime, + val code: String, + val message: String, + @JsonInclude(JsonInclude.Include.NON_NULL) + val data: T? +) { + @Generated + companion object { + fun ok() = ApplicationResponse( + localDateTime = LocalDateTime.now(), + code = "SUCCESS", + message = "성공", + data = null + ) + + fun ok(data: T): ApplicationResponse = ApplicationResponse( + localDateTime = LocalDateTime.now(), + code = "SUCCESS", + message = "성공", + data = data + ) + + fun error(errorCode: CustomErrorCode) = ApplicationResponse( + localDateTime = LocalDateTime.now(), + code = errorCode.code, + message = errorCode.message, + data = null + ) + + fun error(errorCode: CustomErrorCode, message: String) = ApplicationResponse( + localDateTime = LocalDateTime.now(), + code = errorCode.code, + message = message, + data = null + ) + } +} diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/AuthenticatedMember.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/AuthenticatedMember.kt new file mode 100644 index 0000000..7600fd9 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/AuthenticatedMember.kt @@ -0,0 +1,10 @@ +package com.ddd.sonnypolabobe.global.security + +import java.util.* + +data class AuthenticatedMember( + val id: String, + val email: String, + val nickname: String, + val expiredAt: Date +) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/CustomUserDetailsService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/CustomUserDetailsService.kt new file mode 100644 index 0000000..18af849 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/CustomUserDetailsService.kt @@ -0,0 +1,18 @@ +package com.ddd.sonnypolabobe.global.security + +import com.ddd.sonnypolabobe.domain.user.repository.UserJooqRepository +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class CustomUserDetailsService( + private val userJooqRepository: UserJooqRepository +) : UserDetailsService { + override fun loadUserByUsername(id: String): UserDetails { + return userJooqRepository.findById(id.toLong()) + ?: throw IllegalArgumentException("해당하는 사용자를 찾을 수 없습니다.") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..484716b --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtAuthenticationFilter.kt @@ -0,0 +1,48 @@ +package com.ddd.sonnypolabobe.global.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class JwtAuthenticationFilter( + private val jwtUtil: JwtUtil, + private val customUserDetailsService: CustomUserDetailsService +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val authorizationHeader = request.getHeader("Authorization") + if(request.requestURI.contains("/api/v1/oauth/re-issue")) { + filterChain.doFilter(request, response) + return + } + + //JWT가 헤더에 있는 경우 + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + + //JWT 유효성 검증 + if (jwtUtil.validateToken(authorizationHeader)) { + val userId = jwtUtil.getAuthenticatedMemberFromToken(authorizationHeader).id + + //유저와 토큰 일치 시 userDetails 생성 + val userDetails = customUserDetailsService.loadUserByUsername(userId) + + //UserDetsils, Password, Role -> 접근권한 인증 Token 생성 + val usernamePasswordAuthenticationToken = + UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) + + //현재 Request의 Security Context에 접근권한 설정 + SecurityContextHolder.getContext().authentication = + usernamePasswordAuthenticationToken + } + } + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtExceptionFilter.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtExceptionFilter.kt new file mode 100644 index 0000000..876a7e6 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtExceptionFilter.kt @@ -0,0 +1,131 @@ +package com.ddd.sonnypolabobe.global.security + +import com.ddd.sonnypolabobe.global.exception.CustomErrorCode +import com.ddd.sonnypolabobe.global.util.DiscordApiClient +import com.ddd.sonnypolabobe.logger +import com.fasterxml.jackson.databind.ObjectMapper +import io.jsonwebtoken.JwtException +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingRequestWrapper +import org.springframework.web.util.ContentCachingResponseWrapper +import org.springframework.web.util.WebUtils +import java.io.UnsupportedEncodingException +import java.util.* + + +@Component +class JwtExceptionFilter( + private val discordApiClient: DiscordApiClient +) : OncePerRequestFilter() { + private val excludedUrls = setOf("/actuator", "/swagger-ui", "/v3/api-docs") + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + try { + val requestWrapper: ContentCachingRequestWrapper = + ContentCachingRequestWrapper(request as HttpServletRequest) + val responseWrapper: ContentCachingResponseWrapper = + ContentCachingResponseWrapper(response as HttpServletResponse) + if (excludeLogging(request.requestURI)) { + filterChain.doFilter(request, response) + } else { + val startedAt = System.currentTimeMillis() + filterChain.doFilter(requestWrapper, responseWrapper) + val endedAt = System.currentTimeMillis() + + val message = "\n" + + "[REQUEST] ${request.method} - ${request.requestURI} ${responseWrapper.status} - ${(endedAt - startedAt) / 10000.0} \n" + + "Headers : ${getHeaders(request)} \n" + + "Parameters : ${getRequestParams(request)} \n" + + "Request body : ${getRequestBody(requestWrapper)} \n" + + "Response body : ${getResponseBody(responseWrapper)}" + logger().error(message) + if (responseWrapper.status >= 400 && getResponseBody(responseWrapper).contains( + CustomErrorCode.INTERNAL_SERVER_EXCEPTION.message + ) + ) { + this.discordApiClient.sendErrorLog(message) + } + } + } catch (e: JwtException) { + response.contentType = "application/json;charset=UTF-8" + response.status = HttpStatus.UNAUTHORIZED.value() + response.characterEncoding = "utf-8" + + val mapper = ObjectMapper() + val errorJson = mapper.createObjectNode() + errorJson.put("message", e.message) + response.writer.write(mapper.writeValueAsString(errorJson)) + } + } + + private fun excludeLogging(requestURI: String): Boolean { + return excludedUrls.any { requestURI.startsWith(it) } + } + + private fun getResponseBody(response: ContentCachingResponseWrapper): String { + var payload: String? = null + response.characterEncoding = "utf-8" + val wrapper = + WebUtils.getNativeResponse(response, ContentCachingResponseWrapper::class.java) + if (wrapper != null) { + val buf = wrapper.contentAsByteArray + if (buf.isNotEmpty()) { + payload = String(buf, 0, buf.size, charset(wrapper.characterEncoding)) + wrapper.copyBodyToResponse() + } + } + return payload ?: " - " + } + + private fun getRequestBody(request: ContentCachingRequestWrapper): String { + request.characterEncoding = "utf-8" + val wrapper = WebUtils.getNativeRequest( + request, + ContentCachingRequestWrapper::class.java + ) + if (wrapper != null) { + val buf = wrapper.contentAsByteArray + if (buf.isNotEmpty()) { + return try { + String(buf, 0, buf.size, charset(wrapper.characterEncoding)) + } catch (e: UnsupportedEncodingException) { + " - " + } + } + } + return " - " + } + + private fun getRequestParams(request: HttpServletRequest): Map { + val parameterMap: MutableMap = HashMap() + request.characterEncoding = "utf-8" + val parameterArray: Enumeration<*> = request.parameterNames + + while (parameterArray.hasMoreElements()) { + val parameterName = parameterArray.nextElement() as String + parameterMap[parameterName] = request.getParameter(parameterName) + } + + return parameterMap + } + + private fun getHeaders(request: HttpServletRequest): Map { + val headerMap: MutableMap = HashMap() + + val headerArray: Enumeration<*> = request.headerNames + while (headerArray.hasMoreElements()) { + val headerName = headerArray.nextElement() as String + headerMap[headerName] = request.getHeader(headerName) + } + return headerMap + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtUtil.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtUtil.kt new file mode 100644 index 0000000..7081300 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtUtil.kt @@ -0,0 +1,132 @@ +package com.ddd.sonnypolabobe.global.security + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.exception.ApplicationException +import com.ddd.sonnypolabobe.global.exception.CustomErrorCode +import com.ddd.sonnypolabobe.logger +import io.jsonwebtoken.* +import io.jsonwebtoken.security.Keys +import jakarta.xml.bind.DatatypeConverter +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.security.Key +import java.util.* + +@Component +class JwtUtil( + @Value("\${jwt.access-key}") + private val accessSecretKey: String, + @Value("\${jwt.validity.access-seconds}") + private val accessTokenExpirationMs: Long, + @Value("\${jwt.refresh-key}") + private val refreshSecretKey: String, + @Value("\${jwt.validity.refresh-seconds}") + private val refreshTokenExpirationMs: Long +) { + + fun generateAccessToken(request: UserDto.Companion.CreateTokenReq): UserDto.Companion.TokenRes { + val now = Date() + val expiredDate = Date(now.time + accessTokenExpirationMs) + val claims: MutableMap = HashMap() + claims["CLAIM_KEY_ID"] = request.id.toString() + claims["CLAIM_EMAIL"] = request.email + claims["CLAIM_NICKNAME"] = request.nickName + val accessToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, getKeyBytes(accessSecretKey)) + .compact() + return UserDto.Companion.TokenRes(accessToken, generateRefreshToken(request), expiredDate, false, request.nickName) + } + + fun generateRefreshToken(request: UserDto.Companion.CreateTokenReq): String { + val now = Date() + val expiredDate = Date(now.time + refreshTokenExpirationMs) + val claims: MutableMap = HashMap() + claims["CLAIM_KEY_ID"] = request.id.toString() + claims["CLAIM_EMAIL"] = request.email + claims["CLAIM_NICKNAME"] = request.nickName + val refreshToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, getKeyBytes(refreshSecretKey)) + .compact() + return refreshToken + } + + fun getAuthenticatedMemberFromToken(accessToken: String): AuthenticatedMember { + val claims = getClaimsFromAccessToken(subPrefix(accessToken), accessSecretKey) + val id = claims["CLAIM_KEY_ID"].toString() + val email = claims["CLAIM_EMAIL"].toString() + val nickname = claims["CLAIM_NICKNAME"].toString() + return AuthenticatedMember(id, email, nickname, claims.expiration) + } + + fun getAuthenticatedMemberFromRefreshToken(refreshToken: String): AuthenticatedMember { + val claims = getClaimsFromAccessToken(subPrefix(refreshToken), refreshSecretKey) + val id = claims["CLAIM_KEY_ID"].toString() + val email = claims["CLAIM_EMAIL"].toString() + val nickname = claims["CLAIM_NICKNAME"].toString() + return AuthenticatedMember(id, email, nickname, claims.expiration) + } + + fun validateRefreshToken(refreshToken: String): Boolean { + try { + val key = getKeyBytes(refreshSecretKey) + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(subPrefix(refreshToken)) + return true + } catch (e: Exception) { + logger().error("error : $e") + throw ApplicationException(CustomErrorCode.JWT_INVALID) + } + } + + fun validateToken(accessToken: String): Boolean { + try { + val key = getKeyBytes(accessSecretKey) + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(subPrefix(accessToken)) + return true + } catch (e: Exception) { + logger().error("error : $e") + throw ApplicationException(CustomErrorCode.JWT_INVALID) + } + } + + fun getClaimsFromAccessToken(token: String, secretKey: String): Claims { + try { + val key = getKeyBytes(secretKey) + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).body + } catch (e: io.jsonwebtoken.security.SecurityException) { + throw ApplicationException(CustomErrorCode.JWT_SIGNATURE) + } catch (e: MalformedJwtException) { + throw ApplicationException(CustomErrorCode.JWT_MALFORMED) + } catch (e: ExpiredJwtException) { + throw ApplicationException(CustomErrorCode.JWT_EXPIRED) + } catch (e: UnsupportedJwtException) { + throw ApplicationException(CustomErrorCode.JWT_UNSUPPORTED) + } catch (e: IllegalArgumentException) { + throw ApplicationException(CustomErrorCode.JWT_ILLEGAL_ARGUMENT) + } + } + + private fun subPrefix(token: String): String { + return if (token.isNotEmpty() && token.startsWith("Bearer ")) { + token.substring(7) + } else { + token + } + } + + private fun getKeyBytes(secretKey: String): ByteArray { + return DatatypeConverter.parseBase64Binary((secretKey)) + } + + private fun getKey(secretKey: String): Key { + val keyBytes = Base64.getDecoder().decode(secretKey) + return Keys.hmacShaKeyFor(keyBytes) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/KakaoDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/KakaoDto.kt new file mode 100644 index 0000000..9ae1626 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/KakaoDto.kt @@ -0,0 +1,43 @@ +package com.ddd.sonnypolabobe.global.security + +import java.util.* + +class KakaoDto { + + companion object { + data class Token( + val access_token: String, + val refresh_token: String, + val token_type: String, + val expires_in: Int, + val refresh_token_expires_in: Int, + val scope: String + ) + + data class UserInfo( + val id: Long, + val connected_at: String, + val properties: Map, + val kakao_account: KakaoAccount + ) + + data class KakaoAccount( + val profile_nickname_needs_agreement: Boolean, + val profile_image_needs_agreement: Boolean, + val profile: Profile, + val has_email: Boolean, + val email_needs_agreement: Boolean, + val is_email_valid: Boolean, + val is_email_verified: Boolean, + val email: String + ) + + data class Profile( + val nickname: String, + val thumbnail_image_url: String, + val profile_image_url: String, + val is_default_image: Boolean, + val is_default_nickname: Boolean + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/LoggingFilter.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/LoggingFilter.kt new file mode 100644 index 0000000..ccd0de8 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/LoggingFilter.kt @@ -0,0 +1,125 @@ +//package com.ddd.sonnypolabobe.global.security +// +//import com.ddd.sonnypolabobe.global.exception.CustomErrorCode +//import com.ddd.sonnypolabobe.global.util.DiscordApiClient +//import com.ddd.sonnypolabobe.global.util.HttpLog +//import com.ddd.sonnypolabobe.logger +//import jakarta.servlet.FilterChain +//import jakarta.servlet.ServletRequest +//import jakarta.servlet.ServletResponse +//import jakarta.servlet.http.HttpServletRequest +//import jakarta.servlet.http.HttpServletResponse +//import org.springframework.stereotype.Component +//import org.springframework.web.filter.GenericFilterBean +//import org.springframework.web.util.ContentCachingRequestWrapper +//import org.springframework.web.util.ContentCachingResponseWrapper +//import org.springframework.web.util.WebUtils +//import java.io.UnsupportedEncodingException +//import java.util.* +// +//@Component +//class LoggingFilter( +// private val discordApiClient: DiscordApiClient +//) : GenericFilterBean() { +// private val excludedUrls = setOf("/actuator", "/swagger-ui", "/v3/api-docs") +// +// override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { +// val requestWrapper: ContentCachingRequestWrapper = +// ContentCachingRequestWrapper(request as HttpServletRequest) +// val responseWrapper: ContentCachingResponseWrapper = +// ContentCachingResponseWrapper(response as HttpServletResponse) +// if (excludeLogging(request.requestURI)) { +// chain.doFilter(request, response) +// } else { +// val startedAt = System.currentTimeMillis() +// chain.doFilter(requestWrapper, responseWrapper) +// val endedAt = System.currentTimeMillis() +// +// logger().info( +// "\n" + +// "[REQUEST] ${request.method} - ${request.requestURI} ${responseWrapper.status} - ${(endedAt - startedAt) / 10000.0} \n" + +// "Headers : ${getHeaders(request)} \n" + +// "Parameters : ${getRequestParams(request)} \n" + +// "Request body : ${getRequestBody(requestWrapper)} \n" + +// "Response body : ${getResponseBody(responseWrapper)}" +// ) +// +// if(responseWrapper.status >= 400 && getResponseBody(responseWrapper).contains(CustomErrorCode.INTERNAL_SERVER_EXCEPTION.message)) { +// this.discordApiClient.sendErrorLog( +// HttpLog( +// request.method, +// request.requestURI, +// responseWrapper.status, +// (endedAt - startedAt) / 10000.0, +// getHeaders(request), +// getRequestParams(request), +// getRequestBody(requestWrapper), +// getResponseBody(responseWrapper) +// ) +// ) +// } +// } +// } +// +// private fun excludeLogging(requestURI: String): Boolean { +// return excludedUrls.any { requestURI.startsWith(it) } +// } +// +// private fun getResponseBody(response: ContentCachingResponseWrapper): String { +// var payload: String? = null +// response.characterEncoding = "utf-8" +// val wrapper = +// WebUtils.getNativeResponse(response, ContentCachingResponseWrapper::class.java) +// if (wrapper != null) { +// val buf = wrapper.contentAsByteArray +// if (buf.isNotEmpty()) { +// payload = String(buf, 0, buf.size, charset(wrapper.characterEncoding)) +// wrapper.copyBodyToResponse() +// } +// } +// return payload ?: " - " +// } +// +// private fun getRequestBody(request: ContentCachingRequestWrapper): String { +// request.characterEncoding = "utf-8" +// val wrapper = WebUtils.getNativeRequest( +// request, +// ContentCachingRequestWrapper::class.java +// ) +// if (wrapper != null) { +// val buf = wrapper.contentAsByteArray +// if (buf.isNotEmpty()) { +// return try { +// String(buf, 0, buf.size, charset(wrapper.characterEncoding)) +// } catch (e: UnsupportedEncodingException) { +// " - " +// } +// } +// } +// return " - " +// } +// +// private fun getRequestParams(request: HttpServletRequest): Map { +// val parameterMap: MutableMap = HashMap() +// request.characterEncoding = "utf-8" +// val parameterArray: Enumeration<*> = request.parameterNames +// +// while (parameterArray.hasMoreElements()) { +// val parameterName = parameterArray.nextElement() as String +// parameterMap[parameterName] = request.getParameter(parameterName) +// } +// +// return parameterMap +// } +// +// private fun getHeaders(request: HttpServletRequest): Map { +// val headerMap: MutableMap = HashMap() +// +// val headerArray: Enumeration<*> = request.headerNames +// while (headerArray.hasMoreElements()) { +// val headerName = headerArray.nextElement() as String +// headerMap[headerName] = request.getHeader(headerName) +// } +// return headerMap +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/RateLimitingInterceptor.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/RateLimitingInterceptor.kt new file mode 100644 index 0000000..95d96fb --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/RateLimitingInterceptor.kt @@ -0,0 +1,24 @@ +package com.ddd.sonnypolabobe.global.security + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor + +@Component +class RateLimitingInterceptor(private val rateLimitingService: RateLimitingService) : + HandlerInterceptor { + + override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { + // 특정 URL 패턴을 필터링합니다. + if (request.requestURI == "/api/v1/boards" && request.method == "POST") { + if (!rateLimitingService.incrementRequestCount()) { + response.status = HttpStatus.TOO_MANY_REQUESTS.value() + response.writer.write("Daily request limit exceeded") + return false + } + } + return true + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/RateLimitingService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/RateLimitingService.kt new file mode 100644 index 0000000..b7dbfd2 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/RateLimitingService.kt @@ -0,0 +1,35 @@ +package com.ddd.sonnypolabobe.global.security + +import com.ddd.sonnypolabobe.logger +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.util.concurrent.ConcurrentHashMap + +@Service +class RateLimitingService( + @Value("\${limit.count}") + private val limit: Int +) { + + private val requestCounts = ConcurrentHashMap() + private val LIMIT = limit + private val REQUEST_KEY = "api_request_count" + + fun incrementRequestCount(): Boolean { + val currentCount = requestCounts.getOrDefault(REQUEST_KEY, 0) + + if (currentCount >= LIMIT) { + return false + } + + requestCounts[REQUEST_KEY] = currentCount + 1 + logger().info("Request count: ${requestCounts[REQUEST_KEY]}") + return true + } + + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + fun resetRequestCount() { + requestCounts.clear() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DateConverter.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DateConverter.kt new file mode 100644 index 0000000..7836126 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DateConverter.kt @@ -0,0 +1,17 @@ +package com.ddd.sonnypolabobe.global.util + +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Date + +object DateConverter { + + fun convertToKst(date: LocalDateTime): LocalDateTime { + return date.plusHours(9) + } + + fun dateToLocalDateTime(date: Date) : LocalDateTime { + return LocalDateTime.ofInstant(date.toInstant(), ZoneId.of("Asia/Seoul")) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DiscordApiClient.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DiscordApiClient.kt new file mode 100644 index 0000000..0b8906b --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DiscordApiClient.kt @@ -0,0 +1,79 @@ +package com.ddd.sonnypolabobe.global.util + +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import java.time.Duration + + +@Component +class DiscordApiClient( + @Value("\${logging.discord.webhook-uri}") + private val discordWebhookUri: String +) { + fun sendDiscordComm(): WebClient = WebClient.builder().baseUrl(discordWebhookUri) + .clientConnector( + ReactorClientHttpConnector( + HttpClient.create().responseTimeout(Duration.ofMillis(2500)) + ) + ) + .build() + + fun sendErrorLog(req: String) { + val embedData: MutableMap = HashMap() + + embedData["title"] = "서버 에러 발생" + + val field1: MutableMap = HashMap() + field1["name"] = "요청" + field1["value"] = req + + embedData["fields"] = listOf>(field1) + + val payload: MutableMap = HashMap() + payload["embeds"] = arrayOf(embedData) + + sendDiscordComm() + .post() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(payload) + .retrieve() + .bodyToMono(Void::class.java) + .subscribe() + } + + fun sendErrorTrace(errorCode: String, message: String?, trace: String) { + val embedData: MutableMap = HashMap() + + embedData["title"] = "서버 에러 발생" + + val field1: MutableMap = HashMap() + field1["name"] = "트레이스" + field1["value"] = trace + + val field2: MutableMap = HashMap() + field2["name"] = "에러 코드" + field2["value"] = errorCode + + val field3: MutableMap = HashMap() + field3["name"] = "메시지" + field3["value"] = message ?: "메시지 없음" + + embedData["fields"] = listOf>(field1, field2, field3) + + val payload: MutableMap = HashMap() + payload["embeds"] = arrayOf(embedData) + + sendDiscordComm() + .post() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(payload) + .retrieve() + .bodyToMono(Void::class.java) + .subscribe() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/HttpLog.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/HttpLog.kt new file mode 100644 index 0000000..a9cfe8a --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/HttpLog.kt @@ -0,0 +1,12 @@ +package com.ddd.sonnypolabobe.global.util + +data class HttpLog( + val requestMethod : String, + val requestURI : String, + val responseStatus : Int, + val elapsedTime : Double, + val headers : Map, + val parameters : Map, + val requestBody : String, + val responseBody : String +) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/S3Util.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/S3Util.kt new file mode 100644 index 0000000..38c84cf --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/S3Util.kt @@ -0,0 +1,67 @@ +package com.ddd.sonnypolabobe.global.util + +import com.amazonaws.HttpMethod +import com.amazonaws.auth.AWSStaticCredentialsProvider +import com.amazonaws.auth.BasicAWSCredentials +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.AmazonS3ClientBuilder +import com.amazonaws.services.s3.model.DeleteObjectRequest +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest +import com.ddd.sonnypolabobe.logger +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.io.File +import java.net.URL +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* + + +@Component +class S3Util( + @Value("\${cloud.aws.credentials.access-key}") + private val accessKey: String, + @Value("\${cloud.aws.credentials.secret-key}") + private val secretKey: String, + @Value("\${cloud.aws.s3.bucket}") + private val bucket: String, + @Value("\${cloud.aws.region.static}") + private val region: String, + @Value("\${running.name}") + private val runningName: String +) { + + fun awsCredentials(): BasicAWSCredentials { + return BasicAWSCredentials(accessKey, secretKey) + } + + fun amazonS3Client(): AmazonS3 { + return AmazonS3ClientBuilder.standard() + .withCredentials(AWSStaticCredentialsProvider(awsCredentials())) + .withRegion(region) + .build() + } + + fun getPreSignedUrl(fileName: String): URL { + val path: String = (runningName + File.separator) + fileName + val request = GeneratePresignedUrlRequest(bucket, path) + request.expiration = Date(Instant.now().plus(2, ChronoUnit.MINUTES).toEpochMilli()) + request.method = HttpMethod.PUT + return amazonS3Client().generatePresignedUrl(request) + } + + fun getImgUrl(fileName: String): String { + val url: URL = amazonS3Client().getUrl(bucket, runningName + File.separator + fileName) + return url.toString() + } + + fun deleteImage(fileUrl: String) { + try { + val fileKey = "$runningName/$fileUrl" + amazonS3Client().deleteObject(DeleteObjectRequest(bucket, fileKey)) + } catch (e: Exception) { + e.printStackTrace() + logger().error("S3 이미지 삭제 실패 fileUrl: {}", fileUrl) + } + } +} diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/UuidConverter.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/UuidConverter.kt new file mode 100644 index 0000000..1abe579 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/UuidConverter.kt @@ -0,0 +1,36 @@ +package com.ddd.sonnypolabobe.global.util + +import com.ddd.sonnypolabobe.global.exception.ApplicationException +import com.ddd.sonnypolabobe.global.exception.CustomErrorCode +import java.nio.ByteBuffer +import java.util.* + +object UuidConverter { + fun byteArrayToUUID(byteArray: ByteArray): UUID { + val byteBuffer = ByteBuffer.wrap(byteArray) + val mostSigBits = byteBuffer.long + val leastSigBits = byteBuffer.long + return UUID(mostSigBits, leastSigBits) + } + + fun uuidToByteArray(uuid: UUID): ByteArray { + val byteBuffer = ByteBuffer.allocate(16) + byteBuffer.putLong(uuid.mostSignificantBits) + byteBuffer.putLong(uuid.leastSignificantBits) + return byteBuffer.array() + } + + fun stringToUUID(uuid: String): UUID { + return try { + UUID.fromString(uuid) + } catch (e: IllegalArgumentException) { + throw ApplicationException( + CustomErrorCode.BOARD_NOT_FOUND + ) + } + } + + fun uuidToString(uuid: UUID): String { + return uuid.toString() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/UuidGenerator.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/UuidGenerator.kt new file mode 100644 index 0000000..26bb83d --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/UuidGenerator.kt @@ -0,0 +1,10 @@ +package com.ddd.sonnypolabobe.global.util + +import com.github.f4b6a3.uuid.UuidCreator +import java.util.* + +object UuidGenerator { + fun create(): UUID { + return UuidCreator.getTimeOrderedEpochPlus1() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/WebClientUtil.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/WebClientUtil.kt new file mode 100644 index 0000000..9a8709a --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/WebClientUtil.kt @@ -0,0 +1,28 @@ +package com.ddd.sonnypolabobe.global.util + +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import java.time.Duration + + +@Component +class WebClientUtil() { + + fun create(baseUrl: String): WebClient { + return WebClient.builder() + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .codecs { it.defaultCodecs().enableLoggingRequestDetails(true) } + .clientConnector( + ReactorClientHttpConnector( + HttpClient.create().responseTimeout(Duration.ofMillis(2500)) + ) + ) + .build() + } + +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..255a97d --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,70 @@ +spring: + config: + activate: + on-profile: dev + datasource: + url: ENC(pz7EEQu20leZT3uKvGuyvt7YhmLSOJ03yp09o3HSmvk8QqSORYES8Y0iqtjYt3An0uSXVGvd+PdNxXF4h1KXbLm6aMSl2K9gWh2XR5GbIpPAKasqkuha20MGAVckb9NRQC3s0IowzCMvt+jOv0Z1RrAnsfzJy08ZbWe41Slcz/VaQU1hSF6HVohMUrBPApDELlW558sM0jWPfi7NPYNGSlyB4f3bYa0WqjOIAAKPVb3ptvrQwmN7Wg==) + username: ENC(6B0VWBlhLIFr2ynq8c8jug==) + password: ENC(klG0liQdqtFK3VDxA89xC1ZIYYzUIz08) + hikari: + minimum-idle: 2 + maximum-pool-size: 2 +# jpa: +# hibernate: +# naming-strategy: org.hibernate.cfg.ImprovedNamingStrategy +# ddl-auto: none +# show-sql: true +# properties: +# hibernate: +# format_sql: true + jooq: + sql-dialect: mysql + + security: + oauth2: + client: + registration: + kakao: + client-id: ENC(BD9NHDqbUHDpLXYtua4QLLznXweUau5/N3dA1IQqKhQW2sWvniKSDTS3+Z9t/oct) + client-secret: ENC(dMTqjTdS4VJz/1Gduapvl1rDDXUUKkp0bilgqRWMI9X4DaAMVDXY13Fb7QMBDUkI) + scope: + - account_email + - profile_nickname + authorization-grant-type: authorization_code + redirect-uri: https://api.polabo.site/api/v1/oauth/sign-in + client-name: Kakao + client-authentication-method: client_secret_post + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + +cloud: + aws: + credentials: + access-key: ENC(PkcItbhMBDzk8j3pjYqRITA5f560l3HOe5rJDCuhxxA=) + secret-key: ENC(f7qtLdA9YxsjCmV7lNEBnNJlWFeZHl9ztH8Mbe8dgK0N8LJSo8VfrKqlNZKR58fP+fXcxrOJP10=) + s3: + bucket: ENC(nLu55KOteQJS+LGFKjfITxZdfaWyZV6C) + region: + static: ap-northeast-2 + +running: + name: dev + +jwt: + access-key: ENC(43L/PYzgJ86ciTJurdPNLgq49TUMtOgF6ke+Z7d4/wEN2WMu2iakpdDlD2C8d9GUNZzFl0Y9ftK4tk545L5TSv05I5OgbYkBGf2m5O51cjUZmSRg897e+5o+Z5aLgwb1+9tcOcawZR/X9lgkA5hTxlzGc7DHf1vMepLDnbaQBk0I56ieDV314JEEqIaeID72) + refresh-key: ENC(Tg3e1h7BGfmOmB1kbSHW/Wdi9QQh34DR6XsD6Su7GkMH236k11Ffl9siE2FsDVvJX3MRmF7szRveRfUZR4DbgiYXzSoC35yGQHAiljtt7Cb77QuofiCLpKMetX6B3egTHFpoJWHqrsn1IRYi31rcYLIWnGff0vAMCy3IX2jnI2PzjVgzW0/UjpqMXspUj3rIuDjK926M/fCijH+xgswjVNqJcnQQmIk+5FUTSyut5tY=) + validity: + access-seconds: ENC(KeRwqvZAr0MfEVmxw8nBQQFEITQ0v/Fl) + refresh-seconds: ENC(RvppXDZLdo/Yw2ycPoRogGU9rumQgnQy) + +logging: + discord: + webhook-uri: ENC(yfeX3WHXQdxkVtasNl5WLv6M/YlN+dVFUurjxGIddstjjipt+KryWKvLu1wDmdGjpuEhUHyaABg4gFWRMk9gNlxSQEE/G1twbuvkOvT0pyFWycVVJ6ryU/v9pDBOS1PSKJY7L3NP66gOGnam6nOvf0Y+F45zZvXj8/sdtR6N798U6fGjFDxOLQ==) + + level: + root: ERROR diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..dfdfd02 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,67 @@ +spring: + config: + activate: + on-profile: local + datasource: + url: jdbc:mysql://localhost:3306/polabo?useUnicode=true&charset=utf8mb4&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull + username: polabo + password: polabo + hikari: + minimum-idle: 2 + maximum-pool-size: 2 +# jpa: +# hibernate: +# naming-strategy: org.hibernate.cfg.ImprovedNamingStrategy +# ddl-auto: none +# show-sql: true +# properties: +# hibernate: +# format_sql: true + jooq: + sql-dialect: mysql + + security: + oauth2: + client: + registration: + kakao: + client-id: ENC(BD9NHDqbUHDpLXYtua4QLLznXweUau5/N3dA1IQqKhQW2sWvniKSDTS3+Z9t/oct) + client-secret: ENC(dMTqjTdS4VJz/1Gduapvl1rDDXUUKkp0bilgqRWMI9X4DaAMVDXY13Fb7QMBDUkI) + scope: + - account_email + - profile_nickname + authorization-grant-type: authorization_code + redirect-uri: https://api-dev.polabo.site/api/v1/oauth/sign-in + client-name: Kakao + client-authentication-method: client_secret_post + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + +cloud: + aws: + credentials: + access-key: ENC(PkcItbhMBDzk8j3pjYqRITA5f560l3HOe5rJDCuhxxA=) + secret-key: ENC(f7qtLdA9YxsjCmV7lNEBnNJlWFeZHl9ztH8Mbe8dgK0N8LJSo8VfrKqlNZKR58fP+fXcxrOJP10=) + s3: + bucket: ENC(nLu55KOteQJS+LGFKjfITxZdfaWyZV6C) + region: + static: ap-northeast-2 + +running: + name: local + +jwt: + access-key: ENC(43L/PYzgJ86ciTJurdPNLgq49TUMtOgF6ke+Z7d4/wEN2WMu2iakpdDlD2C8d9GUNZzFl0Y9ftK4tk545L5TSv05I5OgbYkBGf2m5O51cjUZmSRg897e+5o+Z5aLgwb1+9tcOcawZR/X9lgkA5hTxlzGc7DHf1vMepLDnbaQBk0I56ieDV314JEEqIaeID72) + validity: + access-seconds: 86400000 + +logging: + discord: + webhook-uri: ENC(yfeX3WHXQdxkVtasNl5WLv6M/YlN+dVFUurjxGIddstjjipt+KryWKvLu1wDmdGjpuEhUHyaABg4gFWRMk9gNlxSQEE/G1twbuvkOvT0pyFWycVVJ6ryU/v9pDBOS1PSKJY7L3NP66gOGnam6nOvf0Y+F45zZvXj8/sdtR6N798U6fGjFDxOLQ==) + level: + root: INFO \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4352ae0..750afff 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,15 @@ spring: application: - name: polabo-api \ No newline at end of file + name: polabo-api + +server: + port: 8080 + shutdown: graceful + + +jasypt: + encryptor: + bean: jasyptStringEncryptor + +limit: + count: 100 \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..f8d6274 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(%-4relative) --- [${appName}, %blue(%X{traceId}), %green(%X{spanId}) %X{sessionId}] %cyan(%logger{20}) : %msg%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file