diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..cffa1e5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +### 요약 + +### 변경한 부분 + +### 질문 + +### 변경한 결과 + + + +--- + +## PR 유형 + +어떤 변경 사항이 있나요? + +- [ ] 새로운 기능 추가 +- [ ] 버그 수정 +- [ ] 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경, 주석 변경) +- [ ] 코드 리팩토링 +- [ ] 문서 수정 +- [ ] 테스트 추가, 테스트 리팩토링 +- [ ] 파일 혹은 폴더명 수정, 삭제 +- [ ] 배포 관련 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..1be7210 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,84 @@ +name: Withaeng CD with Gradle, Github Actions + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + environment: prod + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: temurin + + - name: Cache Gradle dependencies + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + shell: bash + + - name: Build with Gradle + run: ./gradlew clean build + shell: bash + + - name: Clean Server Directory + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: sudo rm -rf /tmp/withaeng-api + + - name: Send build artifacts to server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_SSH_KEY }} + source: withaeng-api + target: /tmp + + deploy: + needs: build + runs-on: ubuntu-latest + environment: prod + + steps: + - name: Run Docker Compose + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + export SPRING_PROFILES_ACTIVE=prod + export DATABASE_DRIVER=${{ secrets.DATABASE_DRIVER }} + export DATABASE_URL=${{ secrets.DATABASE_URL }} + export DATABASE_USERNAME=${{ secrets.DATABASE_USERNAME }} + export DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} + export WITHAENG_HOST=${{ secrets.WITHAENG_HOST }} + export JWT_ISSUER=${{ secrets.JWT_ISSUER }} + export JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} + export AWS_ACCESS_KEY=${{ secrets.AWS_ACCESS_KEY }} + export AWS_SECRET_KEY=${{ secrets.AWS_SECRET_KEY }} + export AWS_SES_REGION=${{ secrets.AWS_SES_REGION }} + export AWS_SES_FROM=${{ secrets.AWS_SES_FROM }} + sudo -E docker compose -f /tmp/withaeng-api/docker-compose.yml down + sudo -E docker compose -f /tmp/withaeng-api/docker-compose.yml up --build -d \ No newline at end of file diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..3b9cbe2 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,29 @@ +name: Pull request workflow (build check) + +on: + pull_request: + branches: + - main + - develop + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: temurin + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew build -x test + + - name: Test with Gradle + run: SPRING_PROFILES_ACTIVE=[test] ./gradlew test diff --git a/.gitignore b/.gitignore index 5a979af..ad17a53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +*-local.properties +*-local.yaml +*-local.yml HELP.md .gradle build/ diff --git a/build.gradle.kts b/build.gradle.kts index 27bb2c2..9728a20 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,45 +1,47 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - id("org.springframework.boot") version "3.2.2" - id("io.spring.dependency-management") version "1.1.4" - kotlin("jvm") version "1.9.22" - kotlin("plugin.spring") version "1.9.22" - kotlin("plugin.jpa") version "1.9.22" + kotlin("jvm") + kotlin("kapt") version "1.9.10" + kotlin("plugin.spring") apply false + kotlin("plugin.jpa") apply false + id("org.springframework.boot") apply false + id("io.spring.dependency-management") apply false } java { sourceCompatibility = JavaVersion.VERSION_17 } +val projectGroup: String by project +val applicationVersion: String by project allprojects { - group = "com.travel" - version = "0.0.1-SNAPSHOT" + group = projectGroup + version = applicationVersion repositories { mavenCentral() } } +val springMockkVersion: String by project.extra + subprojects { apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "org.jetbrains.kotlin.kapt") apply(plugin = "org.jetbrains.kotlin.plugin.spring") + apply(plugin = "org.jetbrains.kotlin.plugin.jpa") apply(plugin = "org.springframework.boot") - apply(plugin = "kotlin") - apply(plugin = "java-library") - apply(plugin = "kotlin-jpa") apply(plugin = "io.spring.dependency-management") - apply(plugin = "kotlin-kapt") - apply(plugin = "application") dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.jetbrains.kotlin:kotlin-reflect") - developmentOnly("org.springframework.boot:spring-boot-devtools") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") // SpringMockk testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("com.ninja-squad:springmockk:3.1.1") + testImplementation("com.ninja-squad:springmockk:$springMockkVersion") runtimeOnly("com.h2database:h2") } @@ -55,11 +57,11 @@ subprojects { useJUnitPlatform() } - tasks.bootJar { + tasks.getByName("bootJar") { enabled = false } - tasks.jar { + tasks.getByName("jar") { enabled = true } } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3596ad7 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,18 @@ +### Application version ### +applicationVersion=0.0.1 +### Project configs ### +projectGroup=com.withaeng +### Project dependency versions ### +kotlinVersion=1.9.22 +### Spring dependency versions ### +springBootVersion=3.2.2 +springDependencyManagementVersion=1.1.4 +### Spring mockk #### +springMockkVersion=3.1.1 +### Persistence versions ### +mysqlVersion=8.3.0 +queryDslVersion=5.1.0:jakarta +### jjwt version +jjwtVersion=0.11.5 +### Swagger Version +swaggerVersion=2.2.0 \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index e9481d9..eea101c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,25 @@ -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" -} rootProject.name = "withaeng" -include("withaeng-api") -include("withaeng-domain") +include( + "withaeng-api", + "withaeng-domain", + "withaeng-common", + "withaeng-external" +) + +pluginManagement { + val kotlinVersion: String by settings + val springBootVersion: String by settings + val springDependencyManagementVersion: String by settings + + resolutionStrategy { + eachPlugin { + when (requested.id.id) { + "org.jetbrains.kotlin.jvm" -> useVersion(kotlinVersion) + "org.jetbrains.kotlin.plugin.spring" -> useVersion(kotlinVersion) + "org.jetbrains.kotlin.plugin.jpa" -> useVersion(kotlinVersion) + "org.springframework.boot" -> useVersion(springBootVersion) + "io.spring.dependency-management" -> useVersion(springDependencyManagementVersion) + } + } + } +} \ No newline at end of file diff --git a/withaeng-api/Dockerfile b/withaeng-api/Dockerfile new file mode 100644 index 0000000..337d1e9 --- /dev/null +++ b/withaeng-api/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:17-jre-alpine +ENV TZ=Asia/Seoul +ENV JAVA_OPTS="-Djava.net.preferIPv4Stack=true" +COPY build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/withaeng-api/build.gradle.kts b/withaeng-api/build.gradle.kts index 76c63a2..cf1a201 100644 --- a/withaeng-api/build.gradle.kts +++ b/withaeng-api/build.gradle.kts @@ -1,12 +1,29 @@ -tasks.bootJar { +tasks.getByName("bootJar") { enabled = true } -tasks.jar { +tasks.getByName("jar") { enabled = false } +val jjwtVersion: String by project.extra +val swaggerVersion: String by project.extra + dependencies { implementation(project(":withaeng-domain")) + implementation(project(":withaeng-common")) + implementation(project(":withaeng-external")) implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-validation") + + api("org.springframework.boot:spring-boot-starter-data-jpa") + + // jwt + implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion") + runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion") + + // Swagger + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$swaggerVersion") } \ No newline at end of file diff --git a/withaeng-api/docker-compose.yml b/withaeng-api/docker-compose.yml new file mode 100644 index 0000000..c340222 --- /dev/null +++ b/withaeng-api/docker-compose.yml @@ -0,0 +1,21 @@ +services: + withaeng-api: + build: + context: . + dockerfile: Dockerfile + container_name: withaeng-api + ports: + - "8080:8080" + environment: + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} + DATABASE_DRIVER: ${DATABASE_DRIVER} + DATABASE_URL: ${DATABASE_URL} + DATABASE_USERNAME: ${DATABASE_USERNAME} + DATABASE_PASSWORD: ${DATABASE_PASSWORD} + WITHAENG_HOST: ${WITHAENG_HOST} + JWT_ISSUER: ${JWT_ISSUER} + JWT_SECRET_KEY: ${JWT_SECRET_KEY} + AWS_ACCESS_KEY: ${AWS_ACCESS_KEY} + AWS_SECRET_KEY: ${AWS_SECRET_KEY} + AWS_SES_REGION: ${AWS_SES_REGION} + AWS_SES_FROM: ${AWS_SES_FROM} diff --git a/withaeng-api/src/main/kotlin/com/travel/withaeng/api/WithaengApplication.kt b/withaeng-api/src/main/kotlin/com/withaeng/WithaengApplication.kt similarity index 88% rename from withaeng-api/src/main/kotlin/com/travel/withaeng/api/WithaengApplication.kt rename to withaeng-api/src/main/kotlin/com/withaeng/WithaengApplication.kt index 64368bf..c6722b7 100644 --- a/withaeng-api/src/main/kotlin/com/travel/withaeng/api/WithaengApplication.kt +++ b/withaeng-api/src/main/kotlin/com/withaeng/WithaengApplication.kt @@ -1,4 +1,4 @@ -package com.travel.withaeng.api +package com.withaeng import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/AccompanyApplicationService.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/AccompanyApplicationService.kt new file mode 100644 index 0000000..a94502a --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/AccompanyApplicationService.kt @@ -0,0 +1,130 @@ +package com.withaeng.api.applicationservice.accompany + +import com.withaeng.api.applicationservice.accompany.dto.* +import com.withaeng.api.applicationservice.common.PagingResponse +import com.withaeng.api.applicationservice.common.toPaging +import com.withaeng.api.common.PageInfoRequest +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.accompany.AccompanyService +import com.withaeng.domain.accompanyjoinrequests.AccompanyJoinRequestService +import com.withaeng.domain.accompanylike.AccompanyLikeService +import com.withaeng.external.image.PreSignedUrl +import com.withaeng.external.image.PreSignedUrlGenerator +import org.springframework.stereotype.Service +import java.util.* + +private const val ACCOMPANY_IMAGE_STORAGE_DIR = "accompany" + +@Service +class AccompanyApplicationService( + private val accompanyService: AccompanyService, + private val accompanyLikeService: AccompanyLikeService, + private val accompanyJoinRequestService: AccompanyJoinRequestService, + private val preSignedUrlGenerator: PreSignedUrlGenerator, +) { + + fun create(request: CreateAccompanyServiceRequest): CreateAccompanyResponse { + if (request.hasImage) { + val preSignedUrl = generatePreSignedUrl() + val createAccompanyDto = request.toDomainDto(preSignedUrl.imageUrl()) + val accompanyDto = accompanyService.create(createAccompanyDto) + return CreateAccompanyResponse(accompanyDto.id, preSignedUrl.uploadUrl()) + } + val accompanyDto = accompanyService.create(request.toDomainDto()) + return CreateAccompanyResponse(accompanyDto.id) + } + + private fun generatePreSignedUrl(): PreSignedUrl { + val objectKey = "$ACCOMPANY_IMAGE_STORAGE_DIR/${UUID.randomUUID()}" + return preSignedUrlGenerator.generate(objectKey) + } + + fun update(request: UpdateAccompanyServiceRequest): AccompanyResponse { + val accompanyDto = accompanyService.update(request.toDomainDto()) + val likeCount = countAccompanyLikeByAccompanyId(request.accompanyId) + return accompanyDto.toAccompanyResponse(likeCount) + } + + fun search( + pageInfoRequest: PageInfoRequest, + request: SearchAccompanyServiceRequest, + ): PagingResponse> { + val pageResult = accompanyService.search(pageInfoRequest.toPageRequest(), request.toQuery()) + .map { accompanyDto -> accompanyDto.toAccompanyResponse() } + return PagingResponse(pageResult.content, pageResult.toPaging()) + } + + fun detail(accompanyId: Long, userId: Long?): FindAccompanyResponse { + increaseViewCount(accompanyId) + val accompanyDto = accompanyService.detail(accompanyId) + + if (isHost(userId, accompanyDto.userId)) { + val joinRequests = accompanyJoinRequestService.findJoinRequestsByAccompanyId(accompanyId) + return accompanyDto.toGuestAccompanyResponse(joinRequests) + } + + return accompanyDto.toHostAccompanyResponse() + } + + fun retrieveAll(): List { + val accompanyDtoList = accompanyService.findAll() + // TODO: Bulk로 가져오는 방법을 고안 + return accompanyDtoList.map { accompanyDto -> + accompanyDto.toAccompanyResponse(countAccompanyLikeByAccompanyId(accompanyDto.id)) + } + } + + fun requestJoin(accompanyId: Long, userId: Long) { + val accompanyDto = accompanyService.findById(accompanyId) + validateSelfRequestNotAllowed(accompanyDto.userId, userId) + accompanyJoinRequestService.create(accompanyId, userId) + } + + fun cancelJoin(accompanyId: Long, userId: Long, joinRequestId: Long) { + val accompanyDto = accompanyService.findById(accompanyId) + val accompanyJoinRequestDto = accompanyJoinRequestService.findById(joinRequestId) + validateSelfRequestNotAllowed(accompanyDto.userId, userId) + validateCreatorAccess(accompanyJoinRequestDto.userId, userId) + accompanyJoinRequestService.cancelJoin(accompanyId, joinRequestId) + } + + fun acceptJoin(accompanyId: Long, userId: Long, joinRequestId: Long) { + accompanyJoinRequestService.acceptJoin(accompanyId, joinRequestId) + } + + fun rejectJoin(accompanyId: Long, userId: Long, joinRequestId: Long) { + val accompanyDto = accompanyService.findById(accompanyId) + validateCreatorAccess(accompanyDto.userId, userId) + accompanyJoinRequestService.rejectJoin(accompanyId, joinRequestId) + } + + private fun increaseViewCount(accompanyId: Long) { + accompanyService.increaseViewCount(accompanyId) + } + + private fun countAccompanyLikeByAccompanyId(accompanyId: Long): Long { + return accompanyLikeService.countByAccompanyId(accompanyId) + } + + private fun validateSelfRequestNotAllowed(createUserId: Long, requestUserId: Long) { + if (createUserId == requestUserId) { + throw WithaengException.of( + type = WithaengExceptionType.ACCESS_DENIED, + message = "본인의 동행은 신청, 취소할 수 없습니다." + ) + } + } + + private fun validateCreatorAccess(createUserId: Long, requestUserId: Long) { + if (createUserId != requestUserId) { + throw WithaengException.of( + type = WithaengExceptionType.ACCESS_DENIED, + message = "접근 권한이 없습니다." + ) + } + } + + private fun isHost(loginUserId: Long?, userId: Long) = + loginUserId == userId +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/dto/AccompanyResponseDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/dto/AccompanyResponseDto.kt new file mode 100644 index 0000000..7dff625 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/dto/AccompanyResponseDto.kt @@ -0,0 +1,87 @@ +package com.withaeng.api.applicationservice.accompany.dto + +import com.withaeng.domain.accompany.AccompanyDestination +import com.withaeng.domain.accompany.AccompanyPreferGender +import com.withaeng.domain.accompany.AccompanyStatus +import com.withaeng.domain.accompany.dto.AccompanyDto +import com.withaeng.domain.accompany.dto.SearchAccompanyDto +import java.time.LocalDate + +data class AccompanyResponse( + val id: Long, + val userId: Long, + val title: String, + val content: String, + val destination: AccompanyDestination, + val startTripDate: LocalDate, + val endTripDate: LocalDate, + val bannerImageUrl: String? = null, + val memberCount: Long, + val viewCount: Long, + val likeCount: Long, + val tags: Set? = null, + val openKakaoUrl: String? = null, + val startAccompanyAge: Int, + val endAccompanyAge: Int, + val preferGender: AccompanyPreferGender, +) + +fun AccompanyDto.toAccompanyResponse(likeCount: Long): AccompanyResponse = AccompanyResponse( + id = id, + userId = userId, + title = title, + content = content, + destination = destination, + startTripDate = startTripDate, + endTripDate = endTripDate, + bannerImageUrl = bannerImageUrl, + memberCount = memberCount, + viewCount = viewCount, + likeCount = likeCount, + openKakaoUrl = openKakaoUrl, + startAccompanyAge = startAccompanyAge.value, + endAccompanyAge = endAccompanyAge.value, + preferGender = preferGender, + tags = tags, +) + +data class AccompanySummaryResponse( + val id: Long, + val bannerImageUrl: String?, + val status: AccompanyStatus, + val startDate: LocalDate, + val endDate: LocalDate, + val currentMemberCount: Long, + val maxMemberCount: Long, + val title: String, + val tags: Set? = null, + val host: AccompanyHostSummaryResponse, +) + +data class AccompanyHostSummaryResponse( + val id: Long, + val profileImageUrl: String?, + val nickname: String, +) + +fun SearchAccompanyDto.toAccompanyResponse(): AccompanySummaryResponse = AccompanySummaryResponse( + id = id, + bannerImageUrl = bannerImageUrl, + status = status, + startDate = startDate, + endDate = endDate, + currentMemberCount = currentMemberCount, + maxMemberCount = maxMemberCount, + title = title, + tags = tags, + host = AccompanyHostSummaryResponse( + id = host.id, + nickname = host.nickname, + profileImageUrl = host.profileImageUrl, + ), +) + +data class CreateAccompanyResponse( + val id: Long, + val preSignedUrl: String? = null, +) \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/dto/AccompanyServiceRequestDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/dto/AccompanyServiceRequestDto.kt new file mode 100644 index 0000000..df9dbd9 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/dto/AccompanyServiceRequestDto.kt @@ -0,0 +1,95 @@ +package com.withaeng.api.applicationservice.accompany.dto + +import com.withaeng.domain.accompany.* +import com.withaeng.domain.accompany.dto.CreateAccompanyDto +import com.withaeng.domain.accompany.dto.SearchAccompanyQuery +import com.withaeng.domain.accompany.dto.UpdateAccompanyDto +import com.withaeng.domain.destination.City +import com.withaeng.domain.destination.Continent +import com.withaeng.domain.destination.Country +import java.time.LocalDate + +data class CreateAccompanyServiceRequest( + val userId: Long, + val title: String, + val content: String, + val continent: String, + val country: String, + val city: String, + val startTripDate: LocalDate, + val endTripDate: LocalDate, + val memberCount: Long, + val tags: Set? = emptySet(), + val openKakaoUrl: String, + val startAccompanyAge: AccompanyAge, + val endAccompanyAge: AccompanyAge, + val preferGender: AccompanyPreferGender, + val hasImage: Boolean = false, +) + +fun CreateAccompanyServiceRequest.toDomainDto(imageUrl: String? = null): CreateAccompanyDto = CreateAccompanyDto( + userId = userId, + title = title, + content = content, + destination = AccompanyDestination( + continent = Continent.valueOf(continent), + country = Country.valueOf(country), + city = City.valueOf(city) + ), + startTripDate = startTripDate, + endTripDate = endTripDate, + bannerImageUrl = imageUrl, + memberCount = memberCount, + tags = tags?.toSet(), + openKakaoUrl = openKakaoUrl, + startAccompanyAge = startAccompanyAge, + endAccompanyAge = endAccompanyAge, + preferGender = preferGender, +) + +data class UpdateAccompanyServiceRequest( + val accompanyId: Long, + val userId: Long, + val content: String? = null, + val tags: Set? = null, +) + +fun UpdateAccompanyServiceRequest.toDomainDto(): UpdateAccompanyDto { + return UpdateAccompanyDto( + accompanyId = accompanyId, + content = content, + tags = tags?.toSet(), + ) +} + +data class SearchAccompanyServiceRequest( + val sort: AccompanySort? = null, + val status: AccompanyStatus? = null, + val continent: Continent? = null, + val country: Country? = null, + val city: City? = null, + val startDate: LocalDate? = null, + val endDate: LocalDate? = null, + val minMemberCount: Int? = null, + val maxMemberCount: Int? = null, + val minAllowedAge: AccompanyAge? = null, + val maxAllowedAge: AccompanyAge? = null, + val preferGender: AccompanyPreferGender? = null, +) + +fun SearchAccompanyServiceRequest.toQuery(): SearchAccompanyQuery { + return SearchAccompanyQuery( + sort = sort, + status = status, + continent = continent, + country = country, + city = city, + startDate = startDate, + endDate = endDate, + minMemberCount = minMemberCount, + maxMemberCount = maxMemberCount, + minAllowedAge = minAllowedAge, + maxAllowedAge = maxAllowedAge, + preferGender = preferGender, + ) +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/dto/FindAccompanyResponseDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/dto/FindAccompanyResponseDto.kt new file mode 100644 index 0000000..303fa6c --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompany/dto/FindAccompanyResponseDto.kt @@ -0,0 +1,71 @@ +package com.withaeng.api.applicationservice.accompany.dto + +import com.withaeng.domain.accompany.AccompanyDestination +import com.withaeng.domain.accompany.AccompanyPreferGender +import com.withaeng.domain.accompany.dto.FindAccompanyDto +import com.withaeng.domain.accompany.dto.FindAccompanyUserInfoDto +import com.withaeng.domain.accompanyjoinrequests.dto.FindAccompanyJoinRequestDto +import java.time.LocalDate + +data class FindAccompanyResponse( + val id: Long, + val userId: Long, + val title: String, + val content: String, + val destination: AccompanyDestination, + val startTripDate: LocalDate, + val endTripDate: LocalDate, + val bannerImageUrl: String? = null, + val memberCount: Long, + val viewCount: Long, + val openKakaoUrl: String, + val startAccompanyAge: Int, + val endAccompanyAge: Int, + val preferGender: AccompanyPreferGender, + val tags: Set? = emptySet(), + val likeCount: Long = 0, + val author: FindAccompanyUserInfoDto, + val joinRequestUsers: List = emptyList(), +) + +fun FindAccompanyDto.toHostAccompanyResponse() = FindAccompanyResponse( + id = id, + userId = userId, + title = title, + content = content, + destination = destination, + startTripDate = startTripDate, + endTripDate = endTripDate, + bannerImageUrl = bannerImageUrl, + memberCount = memberCount, + viewCount = viewCount, + likeCount = likeCount, + openKakaoUrl = openKakaoUrl, + startAccompanyAge = startAccompanyAge, + endAccompanyAge = endAccompanyAge, + preferGender = preferGender, + tags = tags, + author = author, +) + +fun FindAccompanyDto.toGuestAccompanyResponse(joinRequests: List) = + FindAccompanyResponse( + id = id, + userId = userId, + title = title, + content = content, + destination = destination, + startTripDate = startTripDate, + endTripDate = endTripDate, + bannerImageUrl = bannerImageUrl, + memberCount = memberCount, + viewCount = viewCount, + likeCount = likeCount, + openKakaoUrl = openKakaoUrl, + startAccompanyAge = startAccompanyAge, + endAccompanyAge = endAccompanyAge, + preferGender = preferGender, + tags = tags, + author = author, + joinRequestUsers = joinRequests, + ) \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanylike/AccompanyLikeApplicationService.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanylike/AccompanyLikeApplicationService.kt new file mode 100644 index 0000000..38b6443 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanylike/AccompanyLikeApplicationService.kt @@ -0,0 +1,23 @@ +package com.withaeng.api.applicationservice.accompanylike + +import com.withaeng.domain.accompanylike.AccompanyLikeService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional(readOnly = true) +@Service +class AccompanyLikeApplicationService( + private val accompanyLikeService: AccompanyLikeService +) { + + @Transactional + fun like(userId: Long, accompanyId: Long) { + accompanyLikeService.createAccompanyLike(userId, accompanyId) + } + + @Transactional + fun dislike(userId: Long, accompanyId: Long) { + accompanyLikeService.deleteAccompanyLike(userId, accompanyId) + } + +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/AccompanyReplyApplicationService.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/AccompanyReplyApplicationService.kt new file mode 100644 index 0000000..54e1a14 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/AccompanyReplyApplicationService.kt @@ -0,0 +1,112 @@ +package com.withaeng.api.applicationservice.accompanyreply + +import com.withaeng.api.applicationservice.accompanyreply.dto.* +import com.withaeng.api.applicationservice.common.PagingResponse +import com.withaeng.api.applicationservice.common.toPaging +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.accompanyreply.AccompanyReplyDto +import com.withaeng.domain.accompanyreply.AccompanyReplyService +import com.withaeng.domain.accompanyreply.AccompanyReplyStatus +import com.withaeng.domain.accompanyreplylike.AccompanyReplyLikeService +import com.withaeng.domain.user.UserService +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class AccompanyReplyApplicationService( + private val userService: UserService, + private val accompanyReplyService: AccompanyReplyService, + private val accompanyReplyLikeService: AccompanyReplyLikeService, +) { + + @Transactional + fun create(request: CreateAccompanyReplyServiceRequest): AccompanyReplyResponse { + val userSimpleDto = userService.findSimpleById(request.userId) + return accompanyReplyService.create( + accompanyId = request.accompanyId, + userId = request.userId, + content = request.content, + parentId = request.parentId + ).toResponse(userSimpleDto) + } + + fun getList(accompanyId: Long, pageable: Pageable): PagingResponse> { + val accompanyReplyPage = accompanyReplyService.getList(accompanyId, pageable) + val accompanyReplyResponseList = accompanyReplyPage.map { it.toResponse() }.toList() + + return PagingResponse(accompanyReplyResponseList, accompanyReplyPage.toPaging()) + } + + @Transactional + fun update(request: UpdateAccompanyReplyServiceRequest): AccompanyReplyResponse { + val accompanyReplyDto = accompanyReplyService.findById(request.accompanyReplyId) + val userSimpleDto = userService.findSimpleById(request.userId) + validateUpdate(request, accompanyReplyDto) + val updated = accompanyReplyService.update( + replyId = accompanyReplyDto.id, + content = request.content, + ) + val likeCount = accompanyReplyLikeService.countAccompanyReplyLikeCount(accompanyReplyDto.id) + return updated.toResponse(userSimpleDto, likeCount) + } + + @Transactional + fun delete(userId: Long, accompanyReplyId: Long, parentId: Long? = null) { + val accompanyReplyDto = accompanyReplyService.findById(accompanyReplyId) + validateDelete(userId, parentId, accompanyReplyDto) + accompanyReplyService.delete(accompanyReplyDto.id) + } + + private fun validateDelete(requestUserId: Long, requestParentId: Long?, accompanyReplyDto: AccompanyReplyDto) { + validateCreator(accompanyReplyDto.userId, requestUserId) + validateDeletionStatus(accompanyReplyDto.status) + validateSubReply(accompanyReplyDto.parentId, requestParentId) + if (requestParentId != null) { + validateParentId(requestParentId, accompanyReplyDto.parentId) + } + } + + private fun validateDeletionStatus(status: AccompanyReplyStatus) { + if (status == AccompanyReplyStatus.DELETED) { + throw WithaengException.of( + type = WithaengExceptionType.INVALID_INPUT, message = "삭제된 댓글 입니다." + ) + } + } + + private fun validateUpdate(request: UpdateAccompanyReplyServiceRequest, accompanyReplyDto: AccompanyReplyDto) { + validateCreator(accompanyReplyDto.userId, request.userId) + validateSubReply(accompanyReplyDto.parentId, request.parentId) + if (request.parentId != null) { + validateParentId(request.parentId, accompanyReplyDto.parentId) + } + } + + private fun validateCreator(createUserId: Long, requestUserId: Long) { + if (createUserId != requestUserId) { + throw WithaengException.of( + type = WithaengExceptionType.ACCESS_DENIED, + ) + } + } + + private fun validateSubReply(parentId: Long?, requestParentId: Long?) { + if (parentId != null && requestParentId == null) { + throw WithaengException.of( + type = WithaengExceptionType.INVALID_INPUT, message = "댓글이 아닙니다." + ) + } + } + + private fun validateParentId(parentId: Long, requestParentId: Long?) { + if (parentId != requestParentId) { + throw WithaengException.of( + type = WithaengExceptionType.INVALID_INPUT, message = "해당 댓글에 대한 대댓글이 아닙니다." + ) + } + } + +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/dto/AccompanyReplyResponseDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/dto/AccompanyReplyResponseDto.kt new file mode 100644 index 0000000..940941b --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/dto/AccompanyReplyResponseDto.kt @@ -0,0 +1,32 @@ +package com.withaeng.api.applicationservice.accompanyreply.dto + +import com.withaeng.api.applicationservice.user.dto.UserSimpleResponse +import com.withaeng.api.applicationservice.user.dto.toSimpleResponse +import com.withaeng.domain.accompanyreply.AccompanyReplyDto +import com.withaeng.domain.accompanyreply.AccompanyReplyStatus +import com.withaeng.domain.user.dto.UserSimpleDto +import java.time.LocalDateTime + +data class AccompanyReplyResponse( + val id: Long, + val author: UserSimpleResponse, + val accompanyId: Long, + val parentId: Long? = null, + val content: String?, + val likeCount: Long = 0L, + val createdAt: LocalDateTime?, + val status: AccompanyReplyStatus, +) + +fun AccompanyReplyDto.toResponse(userSimpleDto: UserSimpleDto, likeCount: Long = 0L): AccompanyReplyResponse { + return AccompanyReplyResponse( + id = id, + accompanyId = accompanyId, + parentId = parentId, + author = userSimpleDto.toSimpleResponse(), + content = content, + likeCount = likeCount, + createdAt = createdAt, + status = status, + ) +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/dto/AccompanyReplyServiceRequestDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/dto/AccompanyReplyServiceRequestDto.kt new file mode 100644 index 0000000..3d6e4cd --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/dto/AccompanyReplyServiceRequestDto.kt @@ -0,0 +1,16 @@ +package com.withaeng.api.applicationservice.accompanyreply.dto + +data class CreateAccompanyReplyServiceRequest( + val userId: Long, + val accompanyId: Long, + val content: String, + val parentId: Long? = null +) + +data class UpdateAccompanyReplyServiceRequest( + val accompanyReplyId: Long, + val userId: Long, + val accompanyId: Long, + val content: String, + val parentId: Long? = null +) \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/dto/FindAccompanyReplyResponse.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/dto/FindAccompanyReplyResponse.kt new file mode 100644 index 0000000..1876623 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreply/dto/FindAccompanyReplyResponse.kt @@ -0,0 +1,30 @@ +package com.withaeng.api.applicationservice.accompanyreply.dto + +import com.withaeng.domain.accompanyreply.AccompanyReplyStatus +import com.withaeng.domain.accompanyreply.FindAccompanyReplyDto +import com.withaeng.domain.accompanyreply.FindAccompanyReplyUserInfoDto +import java.time.LocalDateTime + +data class FindAccompanyReplyResponse( + val id: Long, + val author: FindAccompanyReplyUserInfoDto, + val accompanyId: Long, + val parentId: Long? = null, + val content: String?, + val likeCount: Long = 0L, + val createdAt: LocalDateTime?, + val status: AccompanyReplyStatus, +) + +fun FindAccompanyReplyDto.toResponse(): FindAccompanyReplyResponse { + return FindAccompanyReplyResponse( + id = id, + accompanyId = accompanyId, + parentId = parentId, + author = author, + content = content, + likeCount = likeCount, + createdAt = createdAt, + status = status, + ) +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreplylike/AccompanyReplyLikeApplicationService.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreplylike/AccompanyReplyLikeApplicationService.kt new file mode 100644 index 0000000..130e7be --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/accompanyreplylike/AccompanyReplyLikeApplicationService.kt @@ -0,0 +1,22 @@ +package com.withaeng.api.applicationservice.accompanyreplylike + +import com.withaeng.domain.accompanyreplylike.AccompanyReplyLikeService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class AccompanyReplyLikeApplicationService( + private val accompanyReplyLikeService: AccompanyReplyLikeService +) { + + @Transactional + fun like(userId: Long, accompanyReplyId: Long) { + accompanyReplyLikeService.createAccompanyReplyLike(userId, accompanyReplyId) + } + + @Transactional + fun dislike(userId: Long, accompanyReplyId: Long) { + accompanyReplyLikeService.deleteAccompanyReplyLike(userId, accompanyReplyId) + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/AuthApplicationService.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/AuthApplicationService.kt new file mode 100644 index 0000000..a62977e --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/AuthApplicationService.kt @@ -0,0 +1,157 @@ +package com.withaeng.api.applicationservice.auth + +import com.withaeng.api.applicationservice.auth.dto.* +import com.withaeng.api.security.authentication.UserInfo +import com.withaeng.api.security.jwt.JwtAgent +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.user.UserRole +import com.withaeng.domain.user.UserService +import com.withaeng.domain.user.dto.UserSimpleDto +import com.withaeng.domain.verificationemail.VerificationEmailService +import com.withaeng.domain.verificationemail.VerificationEmailType +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.* + +@Service +@Transactional(readOnly = true) +class AuthApplicationService( + private val userService: UserService, + private val verificationEmailService: VerificationEmailService, + private val jwtAgent: JwtAgent, + private val passwordEncoder: PasswordEncoder, +) { + + @Transactional + fun signUp(request: SignUpServiceRequest): UserResponse { + val userEmail = request.email + val userDto = userService.findByEmailOrNull(request.email) + if (userDto != null) { + if (userDto.isValidUser()) { + throw WithaengException.of( + type = WithaengExceptionType.ALREADY_EXIST, + message = "이미 가입된 이메일입니다." + ) + } + userService.deleteByEmail(userEmail) + verificationEmailService.deleteAllByUserId(userDto.id) + } + val newUserDto = userService.create( + request.toCommand( + UserNicknameUtils.createTemporaryNickname(), + passwordEncoder.encode(request.password) + ) + ) + verificationEmailService.create( + email = newUserDto.email, + userId = newUserDto.id, + code = UUID.randomUUID().toString(), + type = VerificationEmailType.VERIFY_EMAIL + ) + return UserResponse(newUserDto.id, newUserDto.email, jwtAgent.provide(UserInfo.from(newUserDto))) + } + + @Transactional + fun signIn(request: SignInServiceRequest): UserResponse { + val userDto = userService.findByEmailOrNull(request.email) + ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "이메일에 해당하는 유저를 찾을 수 없습니다." + ) + checkValidUserPassword(request.password, userDto.password) + return UserResponse(userDto.id, userDto.email, jwtAgent.provide(UserInfo.from(userDto))) + } + + @Transactional + fun resendEmail(request: ResendEmailServiceRequest) { + val userDto = userService.findByEmailOrNull(request.email) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "이메일에 해당하는 유저를 찾을 수 없습니다." + ) + if (userDto.isValidUser()) { + throw WithaengException.of( + type = WithaengExceptionType.INVALID_ACCESS, + message = "이미 인증된 유저입니다." + ) + } + verificationEmailService.deleteAllByUserIdAndEmailType(userDto.id, VerificationEmailType.VERIFY_EMAIL) + verificationEmailService.create( + email = userDto.email, + userId = userDto.id, + code = UUID.randomUUID().toString(), + type = VerificationEmailType.VERIFY_EMAIL + ) + } + + @Transactional + fun verifyEmail(request: VerifyEmailServiceRequest) { + val requestedEmail = request.email + val userDto = userService.findByEmailOrNull(requestedEmail) + ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "이메일에 해당하는 유저를 찾을 수 없습니다." + ) + if (userDto.isValidUser()) { + throw WithaengException.of( + type = WithaengExceptionType.INVALID_ACCESS, + message = "이미 인증된 유저입니다." + ) + } + verifyEmailCode( + email = requestedEmail, + userId = userDto.id, + code = request.code + ) + userService.grantUserRole(userDto.id) + } + + @Transactional + fun sendEmailForChangingPassword(request: SendEmailForChangePasswordServiceRequest) { + val userDto = userService.findByEmailOrNull(request.email) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "이메일에 해당하는 유저를 찾을 수 없습니다." + ) + verificationEmailService.deleteAllByUserIdAndEmailType(userDto.id, VerificationEmailType.CHANGE_PASSWORD) + verificationEmailService.create( + email = userDto.email, + userId = userDto.id, + code = UUID.randomUUID().toString(), + type = VerificationEmailType.CHANGE_PASSWORD + ) + } + + @Transactional + fun changePassword(request: ChangePasswordServiceRequest) { + val email = request.email + val userDto = userService.findByEmailOrNull(email) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "이메일에 해당하는 유저를 찾을 수 없습니다." + ) + verifyEmailCode( + email = email, + userId = userDto.id, + code = request.code + ) + userService.replacePassword(userDto.id, passwordEncoder.encode(request.password)) + } + + private fun verifyEmailCode(email: String, userId: Long, code: String) { + val verificationEmailDto = verificationEmailService.findByEmail(email) + if (verificationEmailDto.userId != userId || verificationEmailDto.code != code) { + throw WithaengException.of(WithaengExceptionType.INVALID_INPUT, "Code가 올바르지 않습니다.") + } + verificationEmailService.deleteById(verificationEmailDto.id) + } + + private fun UserSimpleDto.isValidUser(): Boolean { + return roles.any { it == UserRole.USER } + } + + private fun checkValidUserPassword(source: String, encryptedPassword: String) { + if (!passwordEncoder.matches(source, encryptedPassword)) { + throw WithaengException.of(WithaengExceptionType.AUTHENTICATION_FAILURE) + } + } +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/UserNicknameUtils.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/UserNicknameUtils.kt new file mode 100644 index 0000000..ff4722e --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/UserNicknameUtils.kt @@ -0,0 +1,14 @@ +package com.withaeng.api.applicationservice.auth + +import java.util.UUID + +object UserNicknameUtils { + + private const val TEMPORARY_NICKNAME_PREFIX = "user-" + private const val TEMPORARY_NICKNAME_SUFFIX_COUNT = 10 + + fun createTemporaryNickname(): String { + val suffix = UUID.randomUUID().toString().replace("-", "").take(TEMPORARY_NICKNAME_SUFFIX_COUNT) + return TEMPORARY_NICKNAME_PREFIX + suffix + } +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/dto/AuthResponseDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/dto/AuthResponseDto.kt new file mode 100644 index 0000000..ba70ed5 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/dto/AuthResponseDto.kt @@ -0,0 +1,7 @@ +package com.withaeng.api.applicationservice.auth.dto + +data class UserResponse( + val userId: Long, + val email: String, + val accessToken: String +) \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/dto/AuthServiceRequestDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/dto/AuthServiceRequestDto.kt new file mode 100644 index 0000000..3b59250 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/auth/dto/AuthServiceRequestDto.kt @@ -0,0 +1,45 @@ +package com.withaeng.api.applicationservice.auth.dto + +import com.withaeng.domain.user.Gender +import com.withaeng.domain.user.dto.CreateUserCommand +import java.time.LocalDate + +data class SignUpServiceRequest( + val email: String, + val password: String, + val birth: LocalDate, + val gender: Gender, +) + +fun SignUpServiceRequest.toCommand(temporaryNickname: String, encodedPassword: String): CreateUserCommand = + CreateUserCommand( + email = email, + password = encodedPassword, + birth = birth, + gender = gender, + nickname = temporaryNickname, + ) + +data class SignInServiceRequest( + val email: String, + val password: String, +) + +data class ResendEmailServiceRequest( + val email: String, +) + +data class VerifyEmailServiceRequest( + val email: String, + val code: String, +) + +data class SendEmailForChangePasswordServiceRequest( + val email: String, +) + +data class ChangePasswordServiceRequest( + val email: String, + val code: String, + val password: String, +) diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/common/PagingResponse.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/common/PagingResponse.kt new file mode 100644 index 0000000..41d89dc --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/common/PagingResponse.kt @@ -0,0 +1,20 @@ +package com.withaeng.api.applicationservice.common + +import org.springframework.data.domain.Page + +data class Paging( + val totalCount: Long, + val page: Int, + val offset: Long +) + +fun Page<*>.toPaging(): Paging = Paging( + totalCount = totalElements, + page = pageable.pageNumber, + offset = pageable.offset +) + +class PagingResponse( + val content: T, + val paging: Paging +) \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/test/TestApplicationService.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/test/TestApplicationService.kt new file mode 100644 index 0000000..72ffbef --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/test/TestApplicationService.kt @@ -0,0 +1,24 @@ +package com.withaeng.api.applicationservice.test + +import com.withaeng.api.security.authentication.UserInfo +import com.withaeng.api.security.jwt.JwtAgent +import com.withaeng.domain.user.UserService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional(readOnly = true) +@Service +class TestApplicationService( + private val jwtAgent: JwtAgent, + private val userService: UserService, +) { + + fun provideUserToken(userId: Long): String { + return jwtAgent.provide(UserInfo.from(userService.findSimpleById(userId))) + } + + @Transactional + fun confirmUser(userId: Long) { + userService.grantUserRole(userId) + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/user/UserApplicationService.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/user/UserApplicationService.kt new file mode 100644 index 0000000..cfac3b9 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/user/UserApplicationService.kt @@ -0,0 +1,81 @@ +package com.withaeng.api.applicationservice.user + +import com.withaeng.api.applicationservice.user.dto.* +import com.withaeng.api.common.IdResponse +import com.withaeng.domain.accompany.AccompanyService +import com.withaeng.domain.user.UserService +import com.withaeng.domain.user.dto.UserSimpleDto +import com.withaeng.external.image.PreSignedUrl +import com.withaeng.external.image.PreSignedUrlGenerator +import com.withaeng.external.image.S3StorageClient +import org.springframework.stereotype.Service +import java.util.* + +private const val USER_IMAGE_STORAGE_DIR = "user" + +@Service +class UserApplicationService( + private val userService: UserService, + private val accompanyService: AccompanyService, + private val preSignedUrlGenerator: PreSignedUrlGenerator, + private val s3StorageClient: S3StorageClient, +) { + + fun getProfile(userId: Long): UserStatisticalProfileResponse { + val userDetail = userService.findDetailById(userId) + val profileCompletionPercentage = userService.getProfileCompletionPercentage(userId) + val accompanyCount = accompanyService.countByUserId(userId) + return UserStatisticalProfileResponse.of( + userDetail = userDetail, + profileCompletionPercentage = profileCompletionPercentage, + accompanyCount = accompanyCount, + ) + } + + fun getTravelPreference(userId: Long): UserTravelPreferenceResponse { + return userService.findDetailById(userId).travelPreference?.toServiceResponse() + ?: UserTravelPreferenceResponse() + } + + fun updateTravelPreference(userId: Long, serviceRequest: UpdateTravelPreferenceServiceRequest): IdResponse { + return userService.replaceTravelPreference(userId, serviceRequest.toCommand()).toIdResponse() + } + + fun updateNickname(id: Long, nickname: String?): IdResponse { + return userService.updateNickname(id, nickname).toIdResponse() + } + + fun putIntroduction(id: Long, introduction: String?): IdResponse { + return userService.putIntroduction(id, introduction).toIdResponse() + } + + fun putProfileImage(id: Long): PutProfileImageResponse { + val preSignedUrl = generatePreSignedUrl() + val userSimpleDto = userService.putProfileImage(id, preSignedUrl.imageUrl()) + return PutProfileImageResponse( + id = userSimpleDto.id, + preSignedUrl = preSignedUrl.uploadUrl(), + ) + } + + private fun generatePreSignedUrl(): PreSignedUrl { + val objectKey = "$USER_IMAGE_STORAGE_DIR/${UUID.randomUUID()}" + return preSignedUrlGenerator.generate(objectKey) + } + + fun deleteProfileImage(id: Long) { + val userSimpleDto = userService.findSimpleById(id) + userSimpleDto.profile.profileImageUrl?.let { + userService.deleteProfileImage(id) + deleteS3Image(it) + } + } + + private fun deleteS3Image(it: String) { + val objectKey = "$USER_IMAGE_STORAGE_DIR/${it.split("/").last()}" + s3StorageClient.delete(objectKey) + } + + fun UserSimpleDto.toIdResponse(): IdResponse = IdResponse(id) + +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/user/dto/UserServiceRequest.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/user/dto/UserServiceRequest.kt new file mode 100644 index 0000000..b1f226f --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/user/dto/UserServiceRequest.kt @@ -0,0 +1,25 @@ +package com.withaeng.api.applicationservice.user.dto + +import com.withaeng.domain.user.* +import com.withaeng.domain.user.dto.UpdateTravelPreferenceCommand + +data class UpdateTravelPreferenceServiceRequest( + val mbti: Set? = emptySet(), + val preferTravelType: UserPreferTravelType? = null, + val preferTravelThemes: Set? = emptySet(), + val consumeStyle: UserConsumeStyle? = null, + val foodRestrictions: Set? = emptySet(), + val smokingType: UserSmokingType? = null, + val drinkingType: UserDrinkingType? = null, +) + +fun UpdateTravelPreferenceServiceRequest.toCommand(): UpdateTravelPreferenceCommand = + UpdateTravelPreferenceCommand( + mbti = mbti, + preferTravelType = preferTravelType, + preferTravelThemes = preferTravelThemes, + consumeStyle = consumeStyle, + foodRestrictions = foodRestrictions, + smokingType = smokingType, + drinkingType = drinkingType + ) diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/user/dto/UserServiceResponse.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/user/dto/UserServiceResponse.kt new file mode 100644 index 0000000..63be36f --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/applicationservice/user/dto/UserServiceResponse.kt @@ -0,0 +1,77 @@ +package com.withaeng.api.applicationservice.user.dto + +import com.withaeng.domain.user.* +import com.withaeng.domain.user.dto.UserDetailDto +import com.withaeng.domain.user.dto.UserSimpleDto +import com.withaeng.domain.user.dto.UserTravelPreferenceDto +import java.time.LocalDate + +data class UserSimpleResponse( + val id: Long, + val email: String, + val nickname: String, +) + +fun UserSimpleDto.toSimpleResponse(): UserSimpleResponse = UserSimpleResponse( + id = id, + email = email, + nickname = profile.nickname +) + +data class UserTravelPreferenceResponse( + val mbti: Set? = emptySet(), + val preferTravelType: UserPreferTravelType? = null, + val preferTravelThemes: Set = emptySet(), + val consumeStyle: UserConsumeStyle? = null, + val foodRestrictions: Set = emptySet(), + val smokingType: UserSmokingType? = null, + val drinkingType: UserDrinkingType? = null, +) + +fun UserTravelPreferenceDto.toServiceResponse(): UserTravelPreferenceResponse = + UserTravelPreferenceResponse( + mbti = mbti, + preferTravelType = preferTravelType, + preferTravelThemes = preferTravelThemes, + consumeStyle = consumeStyle, + foodRestrictions = foodRestrictions, + smokingType = smokingType, + drinkingType = drinkingType + ) + +data class UserStatisticalProfileResponse( + val id: Long, + val nickname: String, + val introduction: String? = null, + val gender: Gender, + val birth: LocalDate, + val profileImageUrl: String? = null, + val profileCompletionPercentage: Int, + val mannerScore: Double, + val accompanyCount: Int, + val createdAt: LocalDate, +) { + companion object { + fun of( + userDetail: UserDetailDto, + profileCompletionPercentage: Int, + accompanyCount: Int, + ): UserStatisticalProfileResponse = UserStatisticalProfileResponse( + id = userDetail.id, + nickname = userDetail.profile.nickname, + introduction = userDetail.profile.introduction, + gender = userDetail.gender, + birth = userDetail.birth, + profileImageUrl = userDetail.profile.profileImageUrl, + profileCompletionPercentage = profileCompletionPercentage, + mannerScore = userDetail.mannerScore, + accompanyCount = accompanyCount, + createdAt = userDetail.createdDate, + ) + } +} + +data class PutProfileImageResponse( + val id: Long, + val preSignedUrl: String? = null, +) diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/common/ApiResponse.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/common/ApiResponse.kt new file mode 100644 index 0000000..d5cd1bd --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/common/ApiResponse.kt @@ -0,0 +1,43 @@ +package com.withaeng.api.common + +import com.withaeng.api.applicationservice.common.Paging +import com.withaeng.api.applicationservice.common.PagingResponse +import com.withaeng.common.exception.WithaengExceptionType + +data class ApiResponse( + val success: Boolean = true, + val data: T? = null, + val error: ApiErrorResponse? = null, + val paging: Paging? = null +) { + companion object { + + fun success(): ApiResponse { + return ApiResponse(success = true, data = null) + } + + fun success(data: T): ApiResponse { + return ApiResponse(success = true, data = data) + } + + fun success(data: PagingResponse): ApiResponse { + return ApiResponse(success = true, data = data.content, paging = data.paging) + } + + fun fail( + exceptionType: WithaengExceptionType, + message: String? + ): ApiResponse { + return ApiResponse( + success = false, + data = null, + error = ApiErrorResponse( + code = exceptionType.errorCode, + message = message ?: exceptionType.message + ) + ) + } + } +} + +data class ApiErrorResponse(val code: String, val message: String?) \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/common/Constants.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/common/Constants.kt new file mode 100644 index 0000000..b706eda --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/common/Constants.kt @@ -0,0 +1,8 @@ +package com.withaeng.api.common + +object Constants { + object Authentication { + const val BEARER_TYPE = "Bearer" + const val BEARER_TOKEN_PREFIX_WITH_WHITESPACE = "$BEARER_TYPE " + } +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/common/ControllerExceptionAdvice.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/common/ControllerExceptionAdvice.kt new file mode 100644 index 0000000..58c7e67 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/common/ControllerExceptionAdvice.kt @@ -0,0 +1,113 @@ +package com.withaeng.api.common + +import com.fasterxml.jackson.databind.JsonMappingException +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.validation.BindException +import org.springframework.web.HttpRequestMethodNotSupportedException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException + +@RestControllerAdvice +class ControllerExceptionAdvice { + + private val logger: Logger = LoggerFactory.getLogger(ControllerExceptionAdvice::class.java) + + @ExceptionHandler(Exception::class) + fun handleException(ex: Exception): ResponseEntity> { + logger.error("Exception handler", ex) + return errorResponse(WithaengExceptionType.SYSTEM_FAIL, ex.message) + } + + @ExceptionHandler(WithaengException::class) + fun handleMoitException(ex: WithaengException): ResponseEntity> { + logger.error("WitheangException handler", ex) + return errorResponse(ex.httpStatusCode, ex.toApiErrorResponse()) + } + + @ExceptionHandler(MissingServletRequestParameterException::class) + protected fun handleMissingServletRequestParameterException(ex: MissingServletRequestParameterException): ResponseEntity> { + logger.error("MissingServletRequestParameterException handler", ex) + return errorResponse(WithaengExceptionType.INVALID_INPUT, ex.message) + } + + @ExceptionHandler(BindException::class) + protected fun handleBindException(ex: BindException): ResponseEntity> { + logger.error("BindException handler", ex) + return errorResponse(WithaengExceptionType.INVALID_INPUT, ex.message) + } + + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + protected fun handleMethodArgumentTypeMismatchException(ex: MethodArgumentTypeMismatchException): ResponseEntity> { + logger.error("MethodArgumentTypeMismatchException handler", ex) + return errorResponse(WithaengExceptionType.METHOD_ARGUMENT_TYPE_MISMATCH_VALUE, ex.message) + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) + protected fun handleHttpRequestMethodNotSupportedException(ex: HttpRequestMethodNotSupportedException): ResponseEntity> { + logger.error("HttpRequestMethodNotSupportedException handler", ex) + return errorResponse(WithaengExceptionType.HTTP_REQUEST_METHOD_NOT_SUPPORTED, ex.message) + } + + @ExceptionHandler(AccessDeniedException::class) + protected fun handleAccessDeniedException(ex: AccessDeniedException): ResponseEntity> { + logger.error("AccessDeniedException handler", ex) + return errorResponse(WithaengExceptionType.ACCESS_DENIED, ex.message) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + protected fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity> { + logger.error("MethodArgumentNotValidException handler", e) + val errorMessage = e.bindingResult.fieldError?.defaultMessage + return errorResponse(WithaengExceptionType.ARGUMENT_NOT_VALID, errorMessage) + } + + @ExceptionHandler(HttpMessageNotReadableException::class) + protected fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity> { + logger.error("HttpMessageNotReadableException handler", e) + + if (e.cause is JsonMappingException) { + val jsonMappingException = e.cause as JsonMappingException + val fieldName = jsonMappingException.path.getOrNull(0)?.fieldName + return errorResponse( + WithaengExceptionType.INVALID_JSON_FIELD, + "${fieldName} 필드 값이 잘못되었습니다." + ) + } + return errorResponse(WithaengExceptionType.JSON_PARSE_ERROR, "잘못된 데이터가 요청되었습니다.") + } + + private fun WithaengException.toApiErrorResponse() = ApiErrorResponse( + code = errorCode, + message = message, + ) + + private fun errorResponse( + exceptionType: WithaengExceptionType, + message: String?, + ) = errorResponse( + exceptionType.httpStatusCode, + ApiErrorResponse(exceptionType.name, message) + ) + + private fun errorResponse(status: Int, errorResponse: ApiErrorResponse) = + errorResponse(HttpStatus.valueOf(status), errorResponse) + + private fun errorResponse(status: HttpStatus, errorResponse: ApiErrorResponse): ResponseEntity> = + ResponseEntity.status(status) + .body( + ApiResponse( + success = false, + data = null, + error = errorResponse, + ) + ) +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/common/IdResponse.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/common/IdResponse.kt new file mode 100644 index 0000000..e82250a --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/common/IdResponse.kt @@ -0,0 +1,5 @@ +package com.withaeng.api.common + +data class IdResponse( + val id: Long, +) \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/common/PageInfoRequest.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/common/PageInfoRequest.kt new file mode 100644 index 0000000..7ec7ea4 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/common/PageInfoRequest.kt @@ -0,0 +1,15 @@ +package com.withaeng.api.common + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.data.domain.PageRequest + +@Schema(description = "[Request] 페이지 정보") +data class PageInfoRequest( + @Schema(description = "요청할 페이지 번호 (0부터 시작)") + private val page: Int = 0, + @Schema(description = "페이지당 데이터 개수") + private val size: Int = 8, +) { + + fun toPageRequest() = PageRequest.of(page, size) +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/common/WhiteList.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/common/WhiteList.kt new file mode 100644 index 0000000..8fef4fe --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/common/WhiteList.kt @@ -0,0 +1,11 @@ +package com.withaeng.api.common + +object WhiteList { + + fun getWhiteListForSecurityConfig(): List = listOf( + // swagger + "/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", + // H2 console + "/h2-console/**", + ) +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/config/AsyncConfig.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/config/AsyncConfig.kt new file mode 100644 index 0000000..1215210 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/config/AsyncConfig.kt @@ -0,0 +1,22 @@ +package com.withaeng.api.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import java.util.concurrent.Executor + +@Configuration +@EnableAsync +class AsyncConfig { + @Bean(name = ["asyncSchedulerExecutor"]) + fun asyncSchedulerExecutor(): Executor { + return ThreadPoolTaskExecutor().apply { + corePoolSize = 5 + maxPoolSize = 5 + queueCapacity = 20 + setThreadNamePrefix("AsyncSchedulerThread-") + setWaitForTasksToCompleteOnShutdown(true) + } + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/config/AuthConfig.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/config/AuthConfig.kt new file mode 100644 index 0000000..b1423cd --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/config/AuthConfig.kt @@ -0,0 +1,25 @@ +package com.withaeng.api.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.withaeng.api.security.jwt.JwtAgent +import com.withaeng.api.security.jwt.JwtAgentImpl +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(AuthProperty::class) +class AuthConfig { + @Bean + fun jwtAgent(mapper: ObjectMapper, authProperty: AuthProperty): JwtAgent { + return JwtAgentImpl( + objectMapper = mapper, + issuer = authProperty.jwtIssuer, + key = authProperty.jwtSecretKey + ) + } +} + +@ConfigurationProperties(prefix = "withaeng.auth") +data class AuthProperty(val jwtSecretKey: String, val jwtIssuer: String) diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/config/JacksonConfig.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/config/JacksonConfig.kt new file mode 100644 index 0000000..3c61287 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/config/JacksonConfig.kt @@ -0,0 +1,37 @@ +package com.withaeng.api.config + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.Module +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer +import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer +import com.withaeng.api.jackson.EnumModule +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +@Configuration +class JacksonConfig { + @Bean + fun jackson2ObjectMapperBuilder(customizers: List): Jackson2ObjectMapperBuilder { + val builder = Jackson2ObjectMapperBuilder() + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .serializerByType(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) + .serializerByType(LocalDate::class.java, LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE)) + .serializerByType(LocalTime::class.java, LocalTimeSerializer(DateTimeFormatter.ISO_LOCAL_TIME)) + customizers.forEach { customizer -> customizer.customize(builder) } + return builder + } + + @Bean + fun enumModule(): Module { + return EnumModule() + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/config/SchedulerConfig.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/config/SchedulerConfig.kt new file mode 100644 index 0000000..7935666 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/config/SchedulerConfig.kt @@ -0,0 +1,11 @@ +package com.withaeng.api.config + +import com.withaeng.api.scheduler.SchedulerBasePackage +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling + +@Configuration +@EnableScheduling +@ComponentScan(basePackageClasses = [SchedulerBasePackage::class]) +class SchedulerConfig \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/config/SecurityConfig.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/config/SecurityConfig.kt new file mode 100644 index 0000000..8e12e9c --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/config/SecurityConfig.kt @@ -0,0 +1,81 @@ +package com.withaeng.api.config + +import com.withaeng.api.common.WhiteList.getWhiteListForSecurityConfig +import com.withaeng.api.security.handler.HttpStatusAccessDeniedHandler +import com.withaeng.api.security.handler.HttpStatusAuthenticationEntryPoint +import com.withaeng.api.security.jwt.JwtAgent +import com.withaeng.api.security.jwt.JwtFilter +import com.withaeng.domain.user.UserRole +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val jwtAgent: JwtAgent, +) { + + companion object { + private const val MAX_CORS_EXPIRE_SECONDS = 3600L + } + + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + return http + .cors { it.configurationSource(corsConfigurationSource()) } + .csrf { it.disable() } + .httpBasic { it.disable() } + .formLogin { it.disable() } + .authorizeHttpRequests { + it + .requestMatchers("/api/v1/auth/**", "/api/v1/test/**", "/api/v1/destinations").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/accompany/**").permitAll() + .anyRequest().hasAnyRole(UserRole.USER.getActualRoleName(), UserRole.ADMIN.getActualRoleName()) + } + .addFilterBefore(JwtFilter(jwtAgent), UsernamePasswordAuthenticationFilter::class.java) + .exceptionHandling { + it.authenticationEntryPoint(HttpStatusAuthenticationEntryPoint()) + it.accessDeniedHandler(HttpStatusAccessDeniedHandler()) + } + .build() + } + + @Bean + fun webSecurityCustomizer(): WebSecurityCustomizer { + return WebSecurityCustomizer { + it.ignoring().requestMatchers(HttpMethod.GET, *getWhiteListForSecurityConfig().toTypedArray()) + } + } + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = CorsConfiguration().apply { + addAllowedOriginPattern("*") + addAllowedMethod("*") + addAllowedHeader("*") + exposedHeaders = listOf(HttpHeaders.AUTHORIZATION, HttpHeaders.CONTENT_TYPE) + maxAge = MAX_CORS_EXPIRE_SECONDS + } + + return UrlBasedCorsConfigurationSource().apply { + registerCorsConfiguration("api/v1/**", configuration) + } + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/config/SwaggerConfig.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/config/SwaggerConfig.kt new file mode 100644 index 0000000..00e5324 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/config/SwaggerConfig.kt @@ -0,0 +1,45 @@ +package com.withaeng.api.config + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType +import io.swagger.v3.oas.annotations.security.SecurityScheme +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.servers.Server +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.context.annotation.Profile + +@Configuration +@SecurityScheme( + name = "Authorization", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + scheme = "bearer" +) +class SwaggerConfig { + + @Bean + fun defaultSwagger(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("Withaeng API") + .version("1.0") + ) + } + + @Profile("prod") + @Primary + @Bean + fun prodSwagger(defaultSwagger: OpenAPI): OpenAPI { + return defaultSwagger + .servers( + listOf( + Server() + .url("https://api.withaeng.com") + .description("Production server"), + ) + ) + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/config/WebConfigurer.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/config/WebConfigurer.kt new file mode 100644 index 0000000..889d301 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/config/WebConfigurer.kt @@ -0,0 +1,32 @@ +package com.withaeng.api.config + +import com.withaeng.api.security.resolver.UserInfoArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.http.converter.ByteArrayHttpMessageConverter +import org.springframework.http.converter.HttpMessageConverter +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.EnableWebMvc +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@EnableWebMvc +@Configuration +class WebConfigurer( + private val jackson2ObjectMapperBuilder: Jackson2ObjectMapperBuilder, +) : WebMvcConfigurer { + + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(UserInfoArgumentResolver()) + } + + override fun configureMessageConverters(converters: MutableList>) { + converters.addAll( + listOf( + ByteArrayHttpMessageConverter(), + MappingJackson2HttpMessageConverter(jackson2ObjectMapperBuilder.build()), + ) + ) + } + +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompany/AccompanyController.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompany/AccompanyController.kt new file mode 100644 index 0000000..d91f84b --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompany/AccompanyController.kt @@ -0,0 +1,160 @@ +package com.withaeng.api.controller.accompany + +import com.withaeng.api.applicationservice.accompany.AccompanyApplicationService +import com.withaeng.api.applicationservice.accompany.dto.AccompanyResponse +import com.withaeng.api.applicationservice.accompany.dto.AccompanySummaryResponse +import com.withaeng.api.applicationservice.accompany.dto.CreateAccompanyResponse +import com.withaeng.api.applicationservice.accompany.dto.FindAccompanyResponse +import com.withaeng.api.common.ApiResponse +import com.withaeng.api.controller.accompany.dto.* +import com.withaeng.api.security.authentication.UserInfo +import com.withaeng.api.security.resolver.GetAuth +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springdoc.core.annotations.ParameterObject +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.* + +@Tag(name = "Accompany", description = "동행 API") +@RestController +@RequestMapping("/api/v1/accompany") +class AccompanyController( + private val accompanyApplicationService: AccompanyApplicationService, +) { + + @Operation( + summary = "Create Accompany API", + description = "동행 게시글 생성 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @ResponseStatus(HttpStatus.OK) + @PostMapping + fun create( + @GetAuth userInfo: UserInfo, + @RequestBody @Valid request: CreateAccompanyRequest, + ): ApiResponse { + return ApiResponse.success( + accompanyApplicationService.create(request.toServiceRequest(userInfo.id)) + ) + } + + @Operation(summary = "Retrieve Accompany API", description = "동행 게시글 단건 조회 API") + @GetMapping("/{accompanyId}") + fun retrieve( + @GetAuth userInfo: UserInfo?, + @Parameter(description = "동행 id") @PathVariable("accompanyId") accompanyId: Long, + ): ApiResponse { + return ApiResponse.success( + accompanyApplicationService.detail(accompanyId, userInfo?.id) + ) + } + + @Operation(summary = "Search Accompany", description = "동행 검색 API (필터링만 지원)") + @GetMapping("/search") + fun search( + @ParameterObject request: SearchAccompanyRequest, + ): ApiResponse> { + return ApiResponse.success( + accompanyApplicationService.search(request.toPageInfoRequest(), request.toServiceRequest()) + ) + } + + @Operation(summary = "Retrieve All Accompany API", description = "모든 동행 게시글 조회 API") + @GetMapping("/all") + fun retrieveAll(): ApiResponse> { + return ApiResponse.success( + accompanyApplicationService.retrieveAll() + ) + } + + @Operation( + summary = "Update Accompany API", + description = "동행 게시글 수정 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @PutMapping("/{accompanyId}") + fun update( + @GetAuth userInfo: UserInfo, + @Parameter(description = "동행 id") @PathVariable accompanyId: Long, + @RequestBody @Valid param: UpdateAccompanyRequest, + ): ApiResponse { + return ApiResponse.success( + accompanyApplicationService.update( + param.toServiceRequest( + accompanyId = accompanyId, + userId = userInfo.id + ) + ) + ) + } + + @Operation( + summary = "Create AccompanyJoinRequests API", + description = "동행 참가 신청 API - Guest", + security = [SecurityRequirement(name = "Authorization")] + ) + @PostMapping("/{accompanyId}/join-requests") + fun requestJoin( + @GetAuth userInfo: UserInfo, + @Parameter(description = "동행 id") @PathVariable accompanyId: Long, + ): ApiResponse { + accompanyApplicationService.requestJoin( + accompanyId = accompanyId, userId = userInfo.id + ) + return ApiResponse.success() + } + + @Operation( + summary = "Create AccompanyJoinRequests API", + description = "동행 참가 취소 API - Guest", + security = [SecurityRequirement(name = "Authorization")] + ) + @PutMapping("/{accompanyId}/join-requests/{joinRequestId}/cancel") + fun cancelJoin( + @GetAuth userInfo: UserInfo, + @Parameter(description = "동행 id") @PathVariable accompanyId: Long, + @Parameter(description = "동행 참가 요청 id") @PathVariable joinRequestId: Long, + ): ApiResponse { + accompanyApplicationService.cancelJoin( + accompanyId = accompanyId, userId = userInfo.id, joinRequestId = joinRequestId + ) + return ApiResponse.success() + } + + @Operation( + summary = "Create AccompanyJoinRequests API", + description = "동행 승인 API - Host", + security = [SecurityRequirement(name = "Authorization")] + ) + @PutMapping("/{accompanyId}/join-requests/{joinRequestId}/accept") + fun acceptJoin( + @GetAuth userInfo: UserInfo, + @Parameter(description = "동행 id") @PathVariable accompanyId: Long, + @Parameter(description = "동행 참가 요청 id") @PathVariable joinRequestId: Long, + ): ApiResponse { + accompanyApplicationService.acceptJoin( + accompanyId = accompanyId, userId = userInfo.id, joinRequestId = joinRequestId + ) + return ApiResponse.success() + } + + @Operation( + summary = "Create AccompanyJoinRequests API", + description = "동행 거부 API - Host", + security = [SecurityRequirement(name = "Authorization")] + ) + @PutMapping("/{accompanyId}/join-requests/{joinRequestId}/reject") + fun rejectJoin( + @GetAuth userInfo: UserInfo, + @Parameter(description = "동행 id") @PathVariable accompanyId: Long, + @Parameter(description = "동행 참가 요청 id") @PathVariable joinRequestId: Long, + ): ApiResponse { + accompanyApplicationService.rejectJoin( + accompanyId = accompanyId, userId = userInfo.id, joinRequestId = joinRequestId + ) + return ApiResponse.success() + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompany/dto/AccompanyRequestDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompany/dto/AccompanyRequestDto.kt new file mode 100644 index 0000000..35da197 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompany/dto/AccompanyRequestDto.kt @@ -0,0 +1,149 @@ +package com.withaeng.api.controller.accompany.dto + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.withaeng.api.applicationservice.accompany.dto.CreateAccompanyServiceRequest +import com.withaeng.api.applicationservice.accompany.dto.SearchAccompanyServiceRequest +import com.withaeng.api.applicationservice.accompany.dto.UpdateAccompanyServiceRequest +import com.withaeng.api.common.PageInfoRequest +import com.withaeng.domain.accompany.* +import com.withaeng.domain.destination.City +import com.withaeng.domain.destination.Continent +import com.withaeng.domain.destination.Country +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Min +import java.time.LocalDate + +@Schema(description = "[Request] 동행 게시글 생성") +data class CreateAccompanyRequest( + @Schema(description = "동행 게시글 제목") + val title: String, + + @Schema(description = "동행 게시글 내용") + val content: String, + + @Schema(description = "동행 목적지의 대륙") + val continent: String, + + @Schema(description = "동행 목적지의 나라") + val country: String, + + @Schema(description = "동행 목적지의 도시") + val city: String, + + @Schema(description = "동행 시작 날짜 (1999-01-01)") + val startTripDate: LocalDate, + + @Schema(description = "동행 종료 날짜 (1999-01-01)") + val endTripDate: LocalDate, + + @Schema(description = "동행 멤버수") + @field:Min(2, message = "멤버 수는 최소 2명 이상(본인 + 동행자 1명 이상)이어야 합니다.") + val memberCount: Long, + + @Schema(description = "동행 게시글에 부착할 태그 아이디 리스트") + val tags: Set? = emptySet(), + + @Schema(description = "동행 게시글에 게시된 오픈 카카오톡 URL") + val openKakaoUrl: String, + + @Schema(description = "동행 시작 연령(누구나 가능의 경우 0)") + @JsonDeserialize(using = AccompanyAgeDeserializer::class) + val startAccompanyAge: AccompanyAge, + + @Schema(description = "동행 시작 연령(누구나 가능의 경우 99)") + @JsonDeserialize(using = AccompanyAgeDeserializer::class) + val endAccompanyAge: AccompanyAge, + + @Schema(description = "동행 선호 성별") + val preferGender: AccompanyPreferGender, + + @Schema(description = "이미지 업로드 여부") + val hasImage: Boolean = false, +) + +@Schema(description = "[Request] 동행 게시글 수정") +fun CreateAccompanyRequest.toServiceRequest( + userId: Long, +): CreateAccompanyServiceRequest = CreateAccompanyServiceRequest( + userId = userId, + title = title, + content = content, + continent = continent, + country = country, + city = city, + startTripDate = startTripDate, + endTripDate = endTripDate, + memberCount = memberCount, + tags = tags, + openKakaoUrl = openKakaoUrl, + startAccompanyAge = startAccompanyAge, + endAccompanyAge = endAccompanyAge, + preferGender = preferGender, + hasImage = hasImage, +) + +data class UpdateAccompanyRequest( + @Schema(description = "동행 게시글 내용") + val content: String? = null, + + @Schema(description = "동행 게시글에 부착할 태그 아이디 리스트") + val tags: Set? = null, +) + +fun UpdateAccompanyRequest.toServiceRequest( + accompanyId: Long, + userId: Long, +): UpdateAccompanyServiceRequest = UpdateAccompanyServiceRequest( + accompanyId = accompanyId, + userId = userId, + content = content, + tags = tags, +) + +data class SearchAccompanyRequest( + @Schema(description = "요청할 페이지 번호 (0부터 시작)") + val page: Int = 0, + @Schema(description = "페이지당 데이터 개수") + val size: Int = 8, + @Schema(description = "동행 정렬 기준") + val sort: AccompanySort? = null, + @Schema(description = "동행 상태") + val status: AccompanyStatus? = null, + @Schema(description = "동행 대륙") + val continent: Continent? = null, + @Schema(description = "동행 나라") + val country: Country? = null, + @Schema(description = "동행 도시") + val city: City? = null, + @Schema(description = "동행 시작 날짜") + val startDate: LocalDate? = null, + @Schema(description = "동행 종료 날짜") + val endDate: LocalDate? = null, + @Schema(description = "동행 최소 인원") + val minMemberCount: Int? = null, + @Schema(description = "동행 최대 인원") + val maxMemberCount: Int? = null, + @Schema(description = "동행 최소 연령대") + val minAllowedAge: AccompanyAge? = null, + @Schema(description = "동행 최대 연령대") + val maxAllowedAge: AccompanyAge? = null, + @Schema(description = "동행 선호 성별") + val preferGender: AccompanyPreferGender? = null, +) + +fun SearchAccompanyRequest.toPageInfoRequest(): PageInfoRequest = PageInfoRequest(page = page, size = size) + +fun SearchAccompanyRequest.toServiceRequest(): SearchAccompanyServiceRequest = SearchAccompanyServiceRequest( + sort = sort, + status = status, + continent = continent, + country = country, + city = city, + startDate = startDate, + endDate = endDate, + minMemberCount = minMemberCount, + maxMemberCount = maxMemberCount, + minAllowedAge = minAllowedAge, + maxAllowedAge = maxAllowedAge, + preferGender = preferGender +) diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanylike/AccompanyLikeController.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanylike/AccompanyLikeController.kt new file mode 100644 index 0000000..11158ae --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanylike/AccompanyLikeController.kt @@ -0,0 +1,49 @@ +package com.withaeng.api.controller.accompanylike + +import com.withaeng.api.applicationservice.accompanylike.AccompanyLikeApplicationService +import com.withaeng.api.common.ApiResponse +import com.withaeng.api.security.authentication.UserInfo +import com.withaeng.api.security.resolver.GetAuth +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.* + +@Tag(name = "Like/Dislike Accompany", description = "동행 좋아요 API") +@RestController +@RequestMapping("/api/v1/accompany") +class AccompanyLikeController( + private val accompanyLikeApplicationService: AccompanyLikeApplicationService +) { + + @Operation( + summary = "Like Accompany API", + description = "동행 게시글 좋아요 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @PostMapping("/{accompanyId}/like") + @ResponseStatus(HttpStatus.CREATED) + fun like( + @GetAuth userInfo: UserInfo, + @PathVariable("accompanyId") accompanyId: Long + ): ApiResponse { + accompanyLikeApplicationService.like(userInfo.id, accompanyId) + return ApiResponse.success() + } + + @Operation( + summary = "Dislike Accompany API", + description = "동행 게시글 좋아요 취소 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @DeleteMapping("/{accompanyId}/like") + fun dislike( + @GetAuth userInfo: UserInfo, + @PathVariable("accompanyId") accompanyId: Long + ): ApiResponse { + accompanyLikeApplicationService.dislike(userInfo.id, accompanyId) + return ApiResponse.success() + } + +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanyreply/AccompanyReplyController.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanyreply/AccompanyReplyController.kt new file mode 100644 index 0000000..9e8edbf --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanyreply/AccompanyReplyController.kt @@ -0,0 +1,173 @@ +package com.withaeng.api.controller.accompanyreply + +import com.withaeng.api.applicationservice.accompanyreply.AccompanyReplyApplicationService +import com.withaeng.api.applicationservice.accompanyreply.dto.AccompanyReplyResponse +import com.withaeng.api.applicationservice.accompanyreply.dto.FindAccompanyReplyResponse +import com.withaeng.api.common.ApiResponse +import com.withaeng.api.common.PageInfoRequest +import com.withaeng.api.controller.accompanyreply.dto.CreateAccompanyReplyRequest +import com.withaeng.api.controller.accompanyreply.dto.UpdateAccompanyReplyRequest +import com.withaeng.api.controller.accompanyreply.dto.toServiceRequest +import com.withaeng.api.security.authentication.UserInfo +import com.withaeng.api.security.resolver.GetAuth +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.* + +@Tag(name = "Accompany Reply", description = "동행 댓글 API") +@RestController +@RequestMapping("/api/v1/accompany") +class AccompanyReplyController( + private val accompanyReplyApplicationService: AccompanyReplyApplicationService +) { + + @Operation( + summary = "Create Accompany Reply API", + description = "동행 댓글 생성 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @PostMapping("/{accompanyId}/reply") + @ResponseStatus(HttpStatus.CREATED) + fun create( + @GetAuth userInfo: UserInfo, + @PathVariable("accompanyId") accompanyId: Long, + @RequestBody request: CreateAccompanyReplyRequest + ): ApiResponse { + return ApiResponse.success( + accompanyReplyApplicationService.create( + request.toServiceRequest( + userId = userInfo.id, + accompanyId = accompanyId + ) + ) + ) + } + + @Operation(summary = "Retrieve All Accompany Replies API", description = "동행 댓글 조회 API") + @GetMapping("/{accompanyId}/reply/search") + fun search( + @PathVariable(name = "accompanyId") accompanyId: Long, + @ModelAttribute pageInfoRequest: PageInfoRequest, + ): ApiResponse> { + return ApiResponse.success( + accompanyReplyApplicationService.getList( + accompanyId, + pageInfoRequest.toPageRequest() + ) + ) + } + + @Operation( + summary = "Update Accompany Reply API", + description = "동행 댓글 수정 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @PutMapping("/{accompanyId}/reply/{replyId}") + fun update( + @GetAuth userInfo: UserInfo, + @PathVariable("accompanyId") accompanyId: Long, + @PathVariable("replyId") replyId: Long, + @RequestBody request: UpdateAccompanyReplyRequest + ): ApiResponse { + return ApiResponse.success( + accompanyReplyApplicationService.update( + request.toServiceRequest( + userId = userInfo.id, + accompanyId = accompanyId, + accompanyReplyId = replyId + ) + ) + ) + } + + @Operation( + summary = "Delete Accompany Reply API", + description = "동행 댓글 삭제 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @DeleteMapping("/{accompanyId}/reply/{replyId}") + fun delete( + @GetAuth userInfo: UserInfo, + @PathVariable("replyId") replyId: Long + ): ApiResponse { + return ApiResponse.success( + accompanyReplyApplicationService.delete( + userId = userInfo.id, + accompanyReplyId = replyId + ) + ) + } + + @Operation( + summary = "Create Accompany Sub Reply API", + description = "동행 댓글의 댓글 생성 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @PostMapping("/{accompanyId}/reply/{replyId}") + @ResponseStatus(HttpStatus.CREATED) + fun createSubReply( + @GetAuth userInfo: UserInfo, + @PathVariable("accompanyId") accompanyId: Long, + @PathVariable("replyId") replyId: Long, + @RequestBody request: CreateAccompanyReplyRequest + ): ApiResponse { + return ApiResponse.success( + accompanyReplyApplicationService.create( + request.toServiceRequest( + userId = userInfo.id, + accompanyId = accompanyId, + parentId = replyId + ) + ) + ) + } + + @Operation( + summary = "Update Accompany Sub Reply API", + description = "동행 댓글의 댓글 수정 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @PutMapping("/{accompanyId}/reply/{replyId}/{subReplyId}") + fun updateSubReply( + @GetAuth userInfo: UserInfo, + @PathVariable("accompanyId") accompanyId: Long, + @PathVariable("replyId") replyId: Long, + @PathVariable("subReplyId") subReplyId: Long, + @RequestBody request: UpdateAccompanyReplyRequest + ): ApiResponse { + return ApiResponse.success( + accompanyReplyApplicationService.update( + request.toServiceRequest( + userId = userInfo.id, + accompanyId = accompanyId, + accompanyReplyId = subReplyId, + parentId = replyId, + ) + ) + ) + } + + @Operation( + summary = "Update Accompany Sub Reply API", + description = "동행 댓글의 댓글 삭제 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @DeleteMapping("/{accompanyId}/reply/{replyId}/{subReplyId}") + fun deleteSubReply( + @GetAuth userInfo: UserInfo, + @PathVariable("accompanyId") accompanyId: Long, + @PathVariable("replyId") replyId: Long, + @PathVariable("subReplyId") subReplyId: Long, + ): ApiResponse { + return ApiResponse.success( + accompanyReplyApplicationService.delete( + userId = userInfo.id, + accompanyReplyId = subReplyId, + parentId = replyId, + ) + ) + } + +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanyreply/dto/AccompanyReplyRequestDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanyreply/dto/AccompanyReplyRequestDto.kt new file mode 100644 index 0000000..4e966cb --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanyreply/dto/AccompanyReplyRequestDto.kt @@ -0,0 +1,42 @@ +package com.withaeng.api.controller.accompanyreply.dto + +import com.withaeng.api.applicationservice.accompanyreply.dto.CreateAccompanyReplyServiceRequest +import com.withaeng.api.applicationservice.accompanyreply.dto.UpdateAccompanyReplyServiceRequest +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "[Request] 동행 댓글 생성") +data class CreateAccompanyReplyRequest( + @Schema(description = "동행 댓글 내용") + val content: String +) + +fun CreateAccompanyReplyRequest.toServiceRequest( + userId: Long, + accompanyId: Long, + parentId: Long? = null +): CreateAccompanyReplyServiceRequest = CreateAccompanyReplyServiceRequest( + userId = userId, + accompanyId = accompanyId, + content = content, + parentId = parentId +) + + +@Schema(description = "[Request] 동행 댓글 수정") +data class UpdateAccompanyReplyRequest( + @Schema(description = "동행 댓글 내용") + val content: String +) + +fun UpdateAccompanyReplyRequest.toServiceRequest( + userId: Long, + accompanyId: Long, + accompanyReplyId: Long, + parentId: Long? = null +): UpdateAccompanyReplyServiceRequest = UpdateAccompanyReplyServiceRequest( + accompanyReplyId = accompanyReplyId, + userId = userId, + accompanyId = accompanyId, + content = content, + parentId = parentId, +) \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanyreplylike/AccompanyReplyLikeController.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanyreplylike/AccompanyReplyLikeController.kt new file mode 100644 index 0000000..2619456 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/accompanyreplylike/AccompanyReplyLikeController.kt @@ -0,0 +1,53 @@ +package com.withaeng.api.controller.accompanyreplylike + +import com.withaeng.api.applicationservice.accompanyreplylike.AccompanyReplyLikeApplicationService +import com.withaeng.api.common.ApiResponse +import com.withaeng.api.security.authentication.UserInfo +import com.withaeng.api.security.resolver.GetAuth +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.* + +@Tag(name = "Accompany Reply Like", description = "동행 댓글 좋아요 API") +@RestController +@RequestMapping("/api/v1/accompany") +class AccompanyReplyLikeController( + private val accompanyReplyLikeApplicationService: AccompanyReplyLikeApplicationService +) { + + @Operation( + summary = "Like Accompany Reply API", + description = "동행 댓글 좋아요 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @PostMapping("/{accompanyId}/reply/{replyId}/like") + fun create( + @GetAuth userInfo: UserInfo, + @PathVariable("replyId") replyId: Long + ): ApiResponse { + accompanyReplyLikeApplicationService.like( + userId = userInfo.id, + accompanyReplyId = replyId + ) + return ApiResponse.success() + } + + @Operation( + summary = "Dislike Accompany Reply API", + description = "동행 댓글 좋아요 취소 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @DeleteMapping("/{accompanyId}/reply/{replyId}/dislike") + fun delete( + @GetAuth userInfo: UserInfo, + @PathVariable("replyId") replyId: Long + ): ApiResponse { + accompanyReplyLikeApplicationService.dislike( + userId = userInfo.id, + accompanyReplyId = replyId + ) + return ApiResponse.success() + } + +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/auth/AuthController.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/auth/AuthController.kt new file mode 100644 index 0000000..080d2d8 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/auth/AuthController.kt @@ -0,0 +1,61 @@ +package com.withaeng.api.controller.auth + +import com.withaeng.api.applicationservice.auth.AuthApplicationService +import com.withaeng.api.applicationservice.auth.dto.UserResponse +import com.withaeng.api.common.ApiResponse +import com.withaeng.api.controller.auth.dto.* +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.* + +@Tag(name = "Auth", description = "인증 API") +@RestController +@RequestMapping("/api/v1/auth") +class AuthController( + private val authApplicationService: AuthApplicationService, +) { + @Operation(summary = "Sign Up API", description = "회원가입 API") + @PostMapping("/sign-up") + fun signUp(@RequestBody request: SignUpRequest): ApiResponse { + return ApiResponse.success( + authApplicationService.signUp(request.toServiceRequest()) + ) + } + + @Operation(summary = "Sign In API", description = "로그인 API") + @PostMapping("/sign-in") + fun signIn(@RequestBody request: SignInRequest): ApiResponse { + return ApiResponse.success( + authApplicationService.signIn(request.toServiceRequest()) + ) + } + + @Operation(summary = "Re-sending Email API", description = "이메일 재전송 API") + @PostMapping("/re-send") + fun resendEmail(@RequestBody request: ResendEmailRequest): ApiResponse { + return ApiResponse.success( + authApplicationService.resendEmail(request.toServiceRequest()) + ) + } + + @Operation(summary = "Email Verify API", description = "이메일 인증 API") + @PutMapping("/validate-email") + fun verifyEmail(@RequestBody request: VerifyEmailRequest): ApiResponse { + authApplicationService.verifyEmail(request.toServiceRequest()) + return ApiResponse.success() + } + + @Operation(summary = "Send Mail For Changing Password API", description = "비밀번호 변경을 위한 이메일 전송") + @PostMapping("/send-email-for-change-password") + fun sendEmailForChangingPassword(@RequestBody request: SendEmailForChangePasswordRequest): ApiResponse { + authApplicationService.sendEmailForChangingPassword(request.toServiceRequest()) + return ApiResponse.success() + } + + @Operation(summary = "Change Password API", description = "비밀번호 변경 API") + @PutMapping("/change-password") + fun changePassword(@RequestBody request: ChangePasswordRequest): ApiResponse { + authApplicationService.changePassword(request.toServiceRequest()) + return ApiResponse.success() + } +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/auth/dto/AuthRequestDto.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/auth/dto/AuthRequestDto.kt new file mode 100644 index 0000000..27abac9 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/auth/dto/AuthRequestDto.kt @@ -0,0 +1,94 @@ +package com.withaeng.api.controller.auth.dto + +import com.withaeng.api.applicationservice.auth.dto.* +import com.withaeng.domain.user.Gender +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +@Schema(description = "[Request] 회원가입") +data class SignUpRequest( + @Schema(description = "회원가입 할 이메일") + val email: String, + + @Schema(description = "회원가입 할 패스워드") + val password: String, + + @Schema(description = "생년월일 format:[2024-05-09]") + val birth: LocalDate, + + @Schema(description = "성별") + val gender: Gender, +) + +fun SignUpRequest.toServiceRequest(): SignUpServiceRequest = SignUpServiceRequest( + email = email, + password = password, + birth = birth, + gender = gender, +) + +@Schema(description = "[Request] 로그인") +data class SignInRequest( + @Schema(description = "로그인 할 이메일") + val email: String, + + @Schema(description = "로그인 할 패스워드") + val password: String, +) + +fun SignInRequest.toServiceRequest(): SignInServiceRequest = SignInServiceRequest( + email = email, + password = password +) + +@Schema(description = "[Request] 이메일 재전송") +data class ResendEmailRequest( + @Schema(description = "재전송 할 이메일") + val email: String, +) + +fun ResendEmailRequest.toServiceRequest(): ResendEmailServiceRequest = ResendEmailServiceRequest( + email = email +) + +@Schema(description = "[Request] 이메일 인증") +data class VerifyEmailRequest( + @Schema(description = "이메일 인증 할 이메일") + val email: String, + + @Schema(description = "이메일 인증으로 보낸 코드 (UUID 형태)") + val code: String, +) + +fun VerifyEmailRequest.toServiceRequest(): VerifyEmailServiceRequest = VerifyEmailServiceRequest( + email = email, + code = code +) + + +@Schema(description = "[Request] 비밀번호 재설정을 위한 이메일 전송") +data class SendEmailForChangePasswordRequest( + @Schema(description = "이메일 인증 할 이메일") + val email: String, +) + +fun SendEmailForChangePasswordRequest.toServiceRequest(): SendEmailForChangePasswordServiceRequest = + SendEmailForChangePasswordServiceRequest(email) + +@Schema(description = "[Request] 비밀번호 재설정") +data class ChangePasswordRequest( + @Schema(description = "이메일 인증 한 이메일") + val email: String, + + @Schema(description = "이메일 인증으로 받은 코드 (UUID 형태)") + val code: String, + + @Schema(description = "새로운 패스워드") + val password: String, +) + +fun ChangePasswordRequest.toServiceRequest(): ChangePasswordServiceRequest = ChangePasswordServiceRequest( + email = email, + code = code, + password = password +) diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/destination/DestinationController.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/destination/DestinationController.kt new file mode 100644 index 0000000..320cf11 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/destination/DestinationController.kt @@ -0,0 +1,52 @@ +package com.withaeng.api.controller.destination + +import com.withaeng.api.common.ApiResponse +import com.withaeng.domain.destination.City +import com.withaeng.domain.destination.Continent +import com.withaeng.domain.destination.Country +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Destination", description = "여행지 API") +@RestController +@RequestMapping("/api/v1/destinations") +class DestinationController { + + @GetMapping + fun getDestinations(): ApiResponse { + return ApiResponse.success( + getDestinationsTree() + ) + } + + fun getDestinationsTree(): GetDestinationsResponse { + val continents = Continent.entries.map { continent -> + val countriesInContinent = Country.entries + .filter { it.continentCode == continent.continentCode } + .map { country -> + val citiesInCountry = City.entries + .filter { it.countryCode == country.countryCode } + .map { it.name } + CountryNode(name = country.name, cities = citiesInCountry) + } + ContinentNode(name = continent.name, countries = countriesInContinent) + } + return GetDestinationsResponse(continents = continents) + } +} + +data class GetDestinationsResponse( + val continents: List, +) + +data class ContinentNode( + val name: String, + val countries: List, +) + +data class CountryNode( + val name: String, + val cities: List, +) \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/test/TestController.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/test/TestController.kt new file mode 100644 index 0000000..cdb8ccd --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/test/TestController.kt @@ -0,0 +1,32 @@ +package com.withaeng.api.controller.test + +import com.withaeng.api.applicationservice.test.TestApplicationService +import com.withaeng.api.common.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.context.annotation.Profile +import org.springframework.web.bind.annotation.* + +@Tag(name = "Test", description = "테스트를 위한 API") +@Profile("!prod") +@RequestMapping("/api/v1/test") +@RestController +class TestController( + private val testApplicationService: TestApplicationService, +) { + + @Operation(summary = "Provide Test Access Token", description = "테스트 토큰 발급") + @GetMapping("/token/{userId}") + fun getToken(@PathVariable userId: Long): ApiResponse { + return ApiResponse.success(testApplicationService.provideUserToken(userId)) + } + + @Operation(summary = "Grant User Role", description = "사용자 인증 처리") + @PostMapping("/user/{userId}/confirm") + fun confirmUser(@PathVariable userId: Long): ApiResponse { + testApplicationService.confirmUser(userId) + return ApiResponse.success() + } + + +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/user/UserMeController.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/user/UserMeController.kt new file mode 100644 index 0000000..9686844 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/user/UserMeController.kt @@ -0,0 +1,126 @@ +package com.withaeng.api.controller.user + +import com.withaeng.api.applicationservice.user.UserApplicationService +import com.withaeng.api.applicationservice.user.dto.PutProfileImageResponse +import com.withaeng.api.applicationservice.user.dto.UserStatisticalProfileResponse +import com.withaeng.api.applicationservice.user.dto.UserTravelPreferenceResponse +import com.withaeng.api.common.ApiResponse +import com.withaeng.api.common.IdResponse +import com.withaeng.api.controller.user.dto.PatchNicknameRequest +import com.withaeng.api.controller.user.dto.PutIntroductionRequest +import com.withaeng.api.controller.user.dto.PutTravelPreferenceRequest +import com.withaeng.api.controller.user.dto.toServiceRequest +import com.withaeng.api.security.authentication.UserInfo +import com.withaeng.api.security.resolver.GetAuth +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.* + +@Tag(name = "User", description = "유저 정보 관리 API") +@RestController +@RequestMapping("/api/v1/user/me") +class UserMeController(private val userApplicationService: UserApplicationService) { + + @Operation( + summary = "Get Statistics", + description = "유저 통계 정보 조회 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @GetMapping("/profile") + fun getProfile( + @GetAuth userInfo: UserInfo, + ): ApiResponse { + return ApiResponse.success( + userApplicationService.getProfile(userInfo.id) + ) + } + + @Operation( + summary = "Get User Detail", + description = "유저 상세 정보 조회 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @GetMapping("/travel-preference") + fun getTravelPreference( + @GetAuth userInfo: UserInfo, + ): ApiResponse { + return ApiResponse.success( + userApplicationService.getTravelPreference(userInfo.id) + ) + } + + @Operation( + summary = "Replace Travel Preference", + description = "여행 선호 정보 업데이트 API", + security = [SecurityRequirement(name = "Authorization")] + ) + @PutMapping("/travel-preference") + fun putTravelPreference( + @GetAuth userInfo: UserInfo, + @RequestBody @Valid request: PutTravelPreferenceRequest, + ): ApiResponse { + return ApiResponse.success( + userApplicationService.updateTravelPreference(userInfo.id, request.toServiceRequest()) + ) + } + + @Operation( + summary = "Replace Introduction", + description = "이전 소개글은 삭제됩니다.", + security = [SecurityRequirement(name = "Authorization")] + ) + @PutMapping("/introduction") + fun putIntroduction( + @GetAuth userInfo: UserInfo, + @RequestBody @Valid request: PutIntroductionRequest, + ): ApiResponse { + return ApiResponse.success( + userApplicationService.putIntroduction(userInfo.id, request.introduction) + ) + } + + @Operation( + summary = "Replace Profile Image", + description = "이전 프로필 이미지는 삭제됩니다.", + security = [SecurityRequirement(name = "Authorization")] + ) + @PutMapping("/profile-image") + fun putProfileImage( + @GetAuth userInfo: UserInfo, + ): ApiResponse { + return ApiResponse.success( + userApplicationService.putProfileImage(userInfo.id) + ) + } + + @Operation( + summary = "Update Nickname", + description = "nickname 값이 null 이면 기존 닉네임이 유지됩니다.", + security = [SecurityRequirement(name = "Authorization")] + ) + @PatchMapping("/nickname") + fun patchNickname( + @GetAuth userInfo: UserInfo, + @RequestBody @Valid request: PatchNicknameRequest, + ): ApiResponse { + return ApiResponse.success( + userApplicationService.updateNickname(userInfo.id, request.nickname) + ) + } + + @Operation( + summary = "Delete Profile Image", + description = "", + security = [SecurityRequirement(name = "Authorization")] + ) + @DeleteMapping("/profile-image") + fun deleteProfileImage( + @GetAuth userInfo: UserInfo, + ): ApiResponse { + return ApiResponse.success( + userApplicationService.deleteProfileImage(userInfo.id) + ) + } +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/controller/user/dto/UserRequest.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/user/dto/UserRequest.kt new file mode 100644 index 0000000..7c11438 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/controller/user/dto/UserRequest.kt @@ -0,0 +1,52 @@ +package com.withaeng.api.controller.user.dto + +import com.withaeng.api.applicationservice.user.dto.UpdateTravelPreferenceServiceRequest +import com.withaeng.domain.user.* +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "[Request] User 프로필 정보를 추가합니다") +data class PatchNicknameRequest( + @Schema(description = "유저 닉네임") + val nickname: String? = null, +) + +@Schema(description = "[Request] User 소개 정보를 추가합니다") +data class PutIntroductionRequest( + @Schema(description = "유저 소개") + val introduction: String? = null, +) + +@Schema(description = "[Request] User 여행 선호 스타일 정보를 추가합니다") +data class PutTravelPreferenceRequest( + @Schema(description = "유저 MBTI") + val mbti: Set? = emptySet(), + + @Schema(description = "여행 선호 지역 (국내 / 해외)") + val preferTravelType: UserPreferTravelType? = null, + + @Schema(description = "여행 관심사 (사진, 음식 등)") + val preferTravelThemes: Set? = emptySet(), + + @Schema(description = "여행 소비 스타일 (가성비, 쓸 때 쓰는 타입 등)") + val consumeStyle: UserConsumeStyle? = null, + + @Schema(description = "못 먹는 음식 (갑각류, 해산물 등)") + val foodRestrictions: Set? = emptySet(), + + @Schema(description = "흡연 타입") + val smokingType: UserSmokingType? = null, + + @Schema(description = "음주 타입") + val drinkingType: UserDrinkingType? = null, +) + +fun PutTravelPreferenceRequest.toServiceRequest(): UpdateTravelPreferenceServiceRequest = + UpdateTravelPreferenceServiceRequest( + mbti = mbti, + preferTravelType = preferTravelType, + preferTravelThemes = preferTravelThemes, + consumeStyle = consumeStyle, + foodRestrictions = foodRestrictions, + smokingType = smokingType, + drinkingType = drinkingType + ) diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/jackson/EnumModule.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/jackson/EnumModule.kt new file mode 100644 index 0000000..b937a72 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/jackson/EnumModule.kt @@ -0,0 +1,22 @@ +package com.withaeng.api.jackson + +import com.fasterxml.jackson.databind.module.SimpleModule +import com.withaeng.api.jackson.deserializer.SimpleEnumDeserializer +import com.withaeng.domain.user.* + +class EnumModule : SimpleModule() { + init { + // User + addSimpleEnumDeserializer() + addSimpleEnumDeserializer() + addSimpleEnumDeserializer() + addSimpleEnumDeserializer() + addSimpleEnumDeserializer() + addSimpleEnumDeserializer() + addSimpleEnumDeserializer() + } + + private inline fun > addSimpleEnumDeserializer() { + addDeserializer(T::class.java, object : SimpleEnumDeserializer(T::class.java) {}) + } +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/jackson/deserializer/SimpleEnumDeserializer.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/jackson/deserializer/SimpleEnumDeserializer.kt new file mode 100644 index 0000000..bb34f9d --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/jackson/deserializer/SimpleEnumDeserializer.kt @@ -0,0 +1,15 @@ +package com.withaeng.api.jackson.deserializer + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer + +open class SimpleEnumDeserializer>( + private val enumType: Class +) : JsonDeserializer() { + + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): T { + val node = parser.text + return java.lang.Enum.valueOf(enumType, node.uppercase()) + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/scheduler/DeleteVerificationEmailScheduler.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/scheduler/DeleteVerificationEmailScheduler.kt new file mode 100644 index 0000000..c78b72d --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/scheduler/DeleteVerificationEmailScheduler.kt @@ -0,0 +1,38 @@ +package com.withaeng.api.scheduler + +import com.withaeng.domain.verificationemail.VerificationEmailService +import com.withaeng.domain.verificationemail.VerificationEmailStatus +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Async +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Component +class DeleteVerificationEmailScheduler( + private val verificationEmailService: VerificationEmailService, +) { + companion object { + private const val VERIFICATION_EMAIL_EXPIRED_MINUTES = 5L + } + + private val log: Logger = LoggerFactory.getLogger(DeleteVerificationEmailScheduler::class.java) + + @Scheduled(cron = "0 * * * * *") + @Async("asyncSchedulerExecutor") + @Transactional + fun deleteEmails() { + val now = LocalDateTime.now() + log.info("Start deleting verification emails at {}", now) + val willDeleteEmails = verificationEmailService.findAllByStatusNot(VerificationEmailStatus.YET) + .filter { emailDto -> emailDto.createdAt.plusMinutes(VERIFICATION_EMAIL_EXPIRED_MINUTES) < now } + .map { emailDto -> emailDto.id } + .toSet() + if (willDeleteEmails.isNotEmpty()) { + verificationEmailService.deleteAllById(willDeleteEmails) + log.info("End deleting {} verification emails at {}", willDeleteEmails.size, LocalDateTime.now()) + } + } +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/scheduler/SchedulerBasePackage.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/scheduler/SchedulerBasePackage.kt new file mode 100644 index 0000000..ac9a698 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/scheduler/SchedulerBasePackage.kt @@ -0,0 +1,3 @@ +package com.withaeng.api.scheduler + +interface SchedulerBasePackage \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/scheduler/SendEmailScheduler.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/scheduler/SendEmailScheduler.kt new file mode 100644 index 0000000..15c593c --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/scheduler/SendEmailScheduler.kt @@ -0,0 +1,82 @@ +package com.withaeng.api.scheduler + +import com.withaeng.domain.verificationemail.VerificationEmailDto +import com.withaeng.domain.verificationemail.VerificationEmailService +import com.withaeng.domain.verificationemail.VerificationEmailStatus +import com.withaeng.domain.verificationemail.VerificationEmailType +import com.withaeng.external.email.template.EmailTemplate +import com.withaeng.external.email.template.TemplatedEmailSender +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.Async +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit + +@Component +class SendEmailScheduler( + private val verificationEmailService: VerificationEmailService, + private val templatedEmailSender: TemplatedEmailSender, + @Value("\${withaeng.host}") private val host: String, +) { + + companion object { + private const val FIXED_DELAY_SECONDS = 20L + private const val VERIFY_EMAIL_REDIRECT_PATH = "/check-email" + private const val CHANGE_PASSWORD_REDIRECT_PATH = "/check-email-pw" + } + + private val log: Logger = LoggerFactory.getLogger(SendEmailScheduler::class.java) + + @Scheduled(timeUnit = TimeUnit.SECONDS, fixedDelay = FIXED_DELAY_SECONDS) + @Async("asyncSchedulerExecutor") + @Transactional + fun sendEmail() { + val now = LocalDateTime.now() + log.info("Start sending emails at {}", now) + + val verificationEmails = getSendTargets() + if (verificationEmails.isEmpty()) { + log.info("No emails to send at {}", now) + return + } + + verificationEmails.forEach(this::sendVerificationEmail) + + val updatedCount = updateEmailStatuses(verificationEmails) + log.info("End sending {} emails at {}", updatedCount, now) + } + + private fun getSendTargets() = verificationEmailService.findAllByStatusNot(VerificationEmailStatus.DONE) + + private fun sendVerificationEmail(verificationEmail: VerificationEmailDto) = + templatedEmailSender.send( + to = verificationEmail.email, + template = verificationEmail.toEmailTemplate(), + variables = mapOf( + "email" to verificationEmail.email, + "redirectUrl" to createRedirectUrl(verificationEmail), + ) + ) + + private fun updateEmailStatuses(verificationEmails: List): Int { + val emailIds = verificationEmails.map { it.id }.toSet() + return verificationEmailService.updateStatusByIds(emailIds, VerificationEmailStatus.DONE) + } + + private fun createRedirectUrl(verificationEmail: VerificationEmailDto): String = + "${host}${verificationEmail.toRedirectPath()}?email=${verificationEmail.email}&code=${verificationEmail.code}" + + private fun VerificationEmailDto.toEmailTemplate(): EmailTemplate = when (this.type) { + VerificationEmailType.VERIFY_EMAIL -> EmailTemplate.VERIFY_EMAIL + VerificationEmailType.CHANGE_PASSWORD -> EmailTemplate.CHANGE_PASSWORD + } + + private fun VerificationEmailDto.toRedirectPath(): String = when (this.type) { + VerificationEmailType.VERIFY_EMAIL -> VERIFY_EMAIL_REDIRECT_PATH + VerificationEmailType.CHANGE_PASSWORD -> CHANGE_PASSWORD_REDIRECT_PATH + } +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/security/authentication/JwtAuthentication.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/security/authentication/JwtAuthentication.kt new file mode 100644 index 0000000..eb10051 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/security/authentication/JwtAuthentication.kt @@ -0,0 +1,28 @@ +package com.withaeng.api.security.authentication + +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority + +class JwtAuthentication(private val userInfo: UserInfo) : Authentication { + + private var isAuthenticated = true + + override fun getName(): String = userInfo.email + + override fun getAuthorities(): Collection = userInfo.roles + .map { userRole -> SimpleGrantedAuthority(userRole.role) } + + override fun getCredentials(): Any = userInfo.id + + override fun getDetails(): Any = userInfo.toString() + + override fun getPrincipal(): Any = userInfo + + override fun isAuthenticated(): Boolean = isAuthenticated + + override fun setAuthenticated(isAuthenticated: Boolean) { + this.isAuthenticated = isAuthenticated + } + +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/security/authentication/UserInfo.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/security/authentication/UserInfo.kt new file mode 100644 index 0000000..4e8d803 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/security/authentication/UserInfo.kt @@ -0,0 +1,20 @@ +package com.withaeng.api.security.authentication + +import com.withaeng.domain.user.UserRole +import com.withaeng.domain.user.dto.UserSimpleDto + +data class UserInfo( + val id: Long, + val email: String, + val roles: Set, +) { + companion object { + fun from(user: UserSimpleDto): UserInfo { + return UserInfo( + id = user.id, + email = user.email, + roles = user.roles + ) + } + } +} diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/security/handler/HttpStatusAccessDeniedHandler.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/security/handler/HttpStatusAccessDeniedHandler.kt new file mode 100644 index 0000000..78672c9 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/security/handler/HttpStatusAccessDeniedHandler.kt @@ -0,0 +1,21 @@ +package com.withaeng.api.security.handler + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.security.web.access.AccessDeniedHandler + +class HttpStatusAccessDeniedHandler : AccessDeniedHandler { + private val log: Logger = LoggerFactory.getLogger(HttpStatusAccessDeniedHandler::class.java) + + override fun handle( + request: HttpServletRequest?, + response: HttpServletResponse, + accessDeniedException: org.springframework.security.access.AccessDeniedException + ) { + log.debug("Access Denied: ${accessDeniedException.message}") + response.status = HttpStatus.FORBIDDEN.value() + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/security/handler/HttpStatusAuthenticationEntryPoint.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/security/handler/HttpStatusAuthenticationEntryPoint.kt new file mode 100644 index 0000000..c1864ff --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/security/handler/HttpStatusAuthenticationEntryPoint.kt @@ -0,0 +1,42 @@ +package com.withaeng.api.security.handler + +import com.fasterxml.jackson.databind.ObjectMapper +import com.withaeng.api.common.ApiResponse +import com.withaeng.common.exception.WithaengExceptionType +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.MediaType +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint + +class HttpStatusAuthenticationEntryPoint : AuthenticationEntryPoint { + private val log: Logger = LoggerFactory.getLogger(HttpStatusAuthenticationEntryPoint::class.java) + private val objectMapper = ObjectMapper() + + companion object { + private const val ATTRIBUTE = "token_exception_message" + } + + override fun commence( + request: HttpServletRequest, response: HttpServletResponse, + authException: AuthenticationException, + ) { + log.debug("Not Authenticated: ${authException.message}") + val attribute = request.getAttribute(ATTRIBUTE) as String + + responseBuilder(response, attribute) + } + + private fun responseBuilder(response: HttpServletResponse, exceptionMessage: String) { + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print( + objectMapper.writeValueAsString( + ApiResponse.fail(WithaengExceptionType.AUTHENTICATION_FAILURE, exceptionMessage) + ) + ) + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/security/jwt/JwtAgent.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/security/jwt/JwtAgent.kt new file mode 100644 index 0000000..3f77446 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/security/jwt/JwtAgent.kt @@ -0,0 +1,23 @@ +package com.withaeng.api.security.jwt + +import com.withaeng.api.security.authentication.UserInfo + +interface JwtAgent { + + /** + * 사용자 정보를 토대로 Jwt Token을 생성합니다. + * JwtToken은 accessToken및 refreshToken을 포함합니다. + * + * @param userInfo UserInfo 로 유저의 정보를 담고 있습니다. + * @return JWT 를 반환합니다. + */ + fun provide(userInfo: UserInfo): String + + /** + * 사용자의 accessToken을 입력받아 UserInfo 객체를 반환합니다. + * + * @param token 사용자가 발급받은 JWT + * @return UserInfo 를 반환합니다. + */ + fun extractUserInfoFromToken(token: String): UserInfo? +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/security/jwt/JwtAgentImpl.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/security/jwt/JwtAgentImpl.kt new file mode 100644 index 0000000..cbfe537 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/security/jwt/JwtAgentImpl.kt @@ -0,0 +1,62 @@ +package com.withaeng.api.security.jwt + +import com.fasterxml.jackson.databind.ObjectMapper +import com.withaeng.api.security.authentication.UserInfo +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import io.jsonwebtoken.JwtParser +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import java.security.Key +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.* + +class JwtAgentImpl( + private val objectMapper: ObjectMapper, + @Value("\${jwt.issuer}") private val issuer: String, + @Value("\${jwt.secret-key}") key: String, +) : JwtAgent { + + private val secretKey: Key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(key)) + private val jwtParser: JwtParser = Jwts.parserBuilder().setSigningKey(secretKey).build() + + override fun provide(userInfo: UserInfo): String { + val issuerDateTime = LocalDateTime.now(ASIA_SEOUL_ZONE_ID) + val expiredDateTime = issuerDateTime.plusDays(TOKEN_EXPIRE_DAY) + return Jwts.builder() + .setHeaderParam(KEY_TOKEN_TYPE, TOKEN_TYPE_JWT) + .setSubject(createSubject(userInfo.email)) + .setAudience("${userInfo.email}|${userInfo.id}") + .setIssuer(issuer) + .setIssuedAt(issuerDateTime.convertToDate()) + .setExpiration(expiredDateTime.convertToDate()) + .claim(KEY_CLAIM_INFO, userInfo) + .signWith(secretKey) + .compact() + } + + override fun extractUserInfoFromToken(token: String): UserInfo { + return jwtParser.parseClaimsJws(token).body?.let { claims -> + objectMapper.convertValue(claims[KEY_CLAIM_INFO], UserInfo::class.java) + } ?: throw WithaengException.of(WithaengExceptionType.INVALID_USER_AUTH_TOKEN) + } + + private fun LocalDateTime.convertToDate(): Date = Date.from(this.toInstant(ASIA_SEOUL_ZONE_OFFSET)) + + private fun createSubject(subject: String): String = "jwt-user-$subject" + + companion object { + private const val KEY_CLAIM_INFO = "info" + private const val KEY_TOKEN_TYPE = "typ" + private const val TOKEN_TYPE_JWT = "JWT" + + private val ASIA_SEOUL_ZONE_ID = ZoneId.of("Asia/Seoul") + private val ASIA_SEOUL_ZONE_OFFSET = ZoneOffset.of("+09:00") + + private const val TOKEN_EXPIRE_DAY = 7L // 임시 JWT 만료기간 + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/security/jwt/JwtFilter.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/security/jwt/JwtFilter.kt new file mode 100644 index 0000000..6bd6300 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/security/jwt/JwtFilter.kt @@ -0,0 +1,65 @@ +package com.withaeng.api.security.jwt + +import com.withaeng.api.common.Constants.Authentication.BEARER_TYPE +import com.withaeng.api.security.authentication.JwtAuthentication +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter + +class JwtFilter( + private val jwtAgent: JwtAgent, +) : OncePerRequestFilter() { + + private val log: Logger = LoggerFactory.getLogger(JwtFilter::class.java) + + companion object { + private const val AUTH_PROVIDER_SPLIT_DELIMITER: String = " " + private const val ATTRIBUTE = "token_exception_message" + } + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + log.debug("JWT Filter doFilterInternal()") + try { + setAuthenticationFromToken(request) + } catch (exception: Exception) { + log.debug("Authentication Failed: Authorization value=${request.getAuthorization()}, Message=${exception.message}") + SecurityContextHolder.clearContext() + request.setAttribute(ATTRIBUTE, exception.message) + } finally { + filterChain.doFilter(request, response) + } + } + + private fun setAuthenticationFromToken(request: HttpServletRequest) { + val authorization = request.getAuthorization() + ?: throw BadCredentialsException(WithaengExceptionType.AUTHENTICATION_FAILURE.message) + log.debug("Parsing token in header: $authorization - Request path: ${request.requestURI}") + getToken(authorization)?.let { token -> + jwtAgent.extractUserInfoFromToken(token)?.let { userInfo -> + log.debug("userInfo: $userInfo") + SecurityContextHolder.getContext().authentication = JwtAuthentication(userInfo) + } + } ?: throw WithaengException.of(WithaengExceptionType.AUTHENTICATION_FAILURE) + + } + + private fun getToken(authorization: String): String? { + val (provider, token) = authorization.split(AUTH_PROVIDER_SPLIT_DELIMITER) + if (provider.uppercase() != BEARER_TYPE.uppercase()) return null + return token + } + + private fun HttpServletRequest.getAuthorization(): String? = getHeader(HttpHeaders.AUTHORIZATION) +} \ No newline at end of file diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/security/resolver/GetAuth.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/security/resolver/GetAuth.kt new file mode 100644 index 0000000..80ae512 --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/security/resolver/GetAuth.kt @@ -0,0 +1,8 @@ +package com.withaeng.api.security.resolver + +import io.swagger.v3.oas.annotations.media.Schema + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +@Schema(hidden = true) +annotation class GetAuth diff --git a/withaeng-api/src/main/kotlin/com/withaeng/api/security/resolver/UserInfoArgumentResolver.kt b/withaeng-api/src/main/kotlin/com/withaeng/api/security/resolver/UserInfoArgumentResolver.kt new file mode 100644 index 0000000..4960add --- /dev/null +++ b/withaeng-api/src/main/kotlin/com/withaeng/api/security/resolver/UserInfoArgumentResolver.kt @@ -0,0 +1,37 @@ +package com.withaeng.api.security.resolver + +import com.withaeng.api.security.authentication.JwtAuthentication +import com.withaeng.api.security.authentication.UserInfo +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import org.springframework.core.MethodParameter +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class UserInfoArgumentResolver : HandlerMethodArgumentResolver { + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.hasParameterAnnotation(GetAuth::class.java) + && parameter.parameter.type == UserInfo::class.java + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any? { + return when (val authentication = SecurityContextHolder.getContext().authentication) { + is AnonymousAuthenticationToken -> null + is JwtAuthentication -> authentication.principal + else -> throw WithaengException.of( + type = WithaengExceptionType.AUTH_ERROR, + message = "The argument of GetAuth annotation is not of type UserInfo class." + ) + } + } +} \ No newline at end of file diff --git a/withaeng-api/src/main/resources/application.yml b/withaeng-api/src/main/resources/application.yml new file mode 100644 index 0000000..c0acbaf --- /dev/null +++ b/withaeng-api/src/main/resources/application.yml @@ -0,0 +1,30 @@ +spring: + application.name: withaeng-api + profiles.default: local + config.import: + - application-domain.yml + - application-external.yml + web.resources.add-mappings: false + +withaeng: + host: ${WITHAENG_HOST} + auth: + jwt-issuer: ${JWT_ISSUER} + jwt-secret-key: ${JWT_SECRET_KEY} + +springdoc: # todo: 개발 종료 후 prod 에서는 비활성화 + swagger-ui: + path: /api-docs + operations-sorter: alpha + api-docs: + groups: + enabled: true + +--- +spring.config.activate.on-profile: local + +--- +spring.config.activate.on-profile: dev + +--- +spring.config.activate.on-profile: prod diff --git a/withaeng-api/src/test/kotlin/com/withaeng/api/applicationservice/user/UserNicknameUtilsTest.kt b/withaeng-api/src/test/kotlin/com/withaeng/api/applicationservice/user/UserNicknameUtilsTest.kt new file mode 100644 index 0000000..a3c64e6 --- /dev/null +++ b/withaeng-api/src/test/kotlin/com/withaeng/api/applicationservice/user/UserNicknameUtilsTest.kt @@ -0,0 +1,22 @@ +package com.withaeng.api.applicationservice.user + +import com.withaeng.api.applicationservice.auth.UserNicknameUtils +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class UserNicknameUtilsTest { + + @Test + fun createNickname() { + // given + val hyphen = '-' + + // when + val nickname = UserNicknameUtils.createTemporaryNickname() + + // then + assertThat(nickname.contains(hyphen)).isTrue() + assertThat(nickname.count { char -> char == hyphen }).isEqualTo(1) + } + +} diff --git a/withaeng-common/build.gradle.kts b/withaeng-common/build.gradle.kts new file mode 100644 index 0000000..405438b --- /dev/null +++ b/withaeng-common/build.gradle.kts @@ -0,0 +1 @@ +dependencies { } \ No newline at end of file diff --git a/withaeng-common/src/main/kotlin/com/withaeng/common/exception/WithaengException.kt b/withaeng-common/src/main/kotlin/com/withaeng/common/exception/WithaengException.kt new file mode 100644 index 0000000..9345ca5 --- /dev/null +++ b/withaeng-common/src/main/kotlin/com/withaeng/common/exception/WithaengException.kt @@ -0,0 +1,25 @@ +package com.withaeng.common.exception + +class WithaengException( + val errorCode: String, + val httpStatusCode: Int, + override val message: String, +) : RuntimeException() { + companion object { + fun of(type: WithaengExceptionType): WithaengException { + return WithaengException( + errorCode = type.errorCode, + httpStatusCode = type.httpStatusCode, + message = type.message, + ) + } + + fun of(type: WithaengExceptionType, message: String): WithaengException { + return WithaengException( + errorCode = type.errorCode, + httpStatusCode = type.httpStatusCode, + message = message, + ) + } + } +} diff --git a/withaeng-common/src/main/kotlin/com/withaeng/common/exception/WithaengExceptionType.kt b/withaeng-common/src/main/kotlin/com/withaeng/common/exception/WithaengExceptionType.kt new file mode 100644 index 0000000..ab7471d --- /dev/null +++ b/withaeng-common/src/main/kotlin/com/withaeng/common/exception/WithaengExceptionType.kt @@ -0,0 +1,39 @@ +package com.withaeng.common.exception + +enum class WithaengExceptionType( + val message: String, + val errorCode: String, + val httpStatusCode: Int, +) { + // USER + AUTH_ERROR("유저 프로세스에서 오류가 발생했습니다.", "U000_AUTH_ERROR", 500), + EMPTY_AUTHORIZATION_HEADER("Not Exist Authorization Header", "U001_EMPTY_AUTHORIZATION_HEADER", 400), + INVALID_USER_AUTH_TOKEN("Invalid JWT Token", "U002_INVALID_TOKEN", 400), + INVALID_AUTH_PROVIDER( + "Invalid provider by auth0. Check social section of auth0", + "U003_INVALID_AUTH_PROVIDER", + 500 + ), + EMPTY_FCM_TOKEN("Not Exist FCM Token", "U004_EMPTY_FCM_TOKEN", 400), + + // COMMON + NOT_EXIST("존재하지 않습니다.", "C001_NOT_EXIST", 404), + SYSTEM_FAIL("Internal Server Error.", "C002_SYSTEM_FAIL", 500), + INVALID_ACCESS("Invalid Access", "C003_INVALID_ACCESS", 403), + ALREADY_EXIST("Already Exist", "C004_ALREADY_EXIST", 409), + INVALID_INPUT("Invalid Input", "C004_INVALID_INPUT", 400), + METHOD_ARGUMENT_TYPE_MISMATCH_VALUE("Request method argument type mismatch", "C005_TYPE_MISMATCH_VALUE", 400), + HTTP_REQUEST_METHOD_NOT_SUPPORTED("HTTP request method not supported", "C006_HTTP_METHOD_NOT_SUPPORTED", 400), + ACCESS_DENIED("Access denied. Check authentication.", "C007_ACCESS_DENIED", 403), + AUTHENTICATION_FAILURE("Authentication failed. Check login.", "C008_AUTHENTICATION_FAILURE", 401), + ARGUMENT_NOT_VALID("Method Argument Not Valid. Check argument validation.", "C009_ARGUMENT_NOT_VALID", 400), + JSON_PARSE_ERROR("Request JSON parsing error", "C010_JSON_PARSE_ERROR", 400), + INVALID_JSON_FIELD("Invalid JSON field value", "C011_INVALID_JSON_FIELD", 400), + + // ACCOMPANY + INVALID_ACCOMPANY_AGE_VALUE("Invalid accompany age value", "A001_INVALID_ACCOMPANY_AGE_VALUE", 400), + + // NOTIFICATION + INVALID_NOTIFICATION_TYPE("Invalid Notification Type", "N001_INVALID_NOTIFICATION_TYPE", 500), + ; +} diff --git a/withaeng-domain/build.gradle.kts b/withaeng-domain/build.gradle.kts index 357b4ff..7fc78e2 100644 --- a/withaeng-domain/build.gradle.kts +++ b/withaeng-domain/build.gradle.kts @@ -1,9 +1,20 @@ -dependencies { - implementation("org.springframework.boot:spring-boot-starter-data-jpa") -} - allOpen { annotation("jakarta.persistence.Entity") annotation("jakarta.persistence.MappedSuperclass") annotation("jakarta.persistence.Embeddable") +} + +val mysqlVersion: String by project.extra +val queryDslVersion: String by project.extra +dependencies { + implementation(project(":withaeng-common")) + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // Query DSL + implementation("com.querydsl:querydsl-jpa:$queryDslVersion") + kapt("com.querydsl:querydsl-apt:$queryDslVersion") + + kapt("org.springframework.boot:spring-boot-configuration-processor") + runtimeOnly("com.mysql:mysql-connector-j:$mysqlVersion") } \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/config/JpaConfig.kt b/withaeng-domain/src/main/kotlin/com/travel/withaeng/config/JpaConfig.kt deleted file mode 100644 index ede6839..0000000 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/config/JpaConfig.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.travel.withaeng.config - -import org.springframework.context.annotation.Configuration -import org.springframework.data.jpa.repository.config.EnableJpaAuditing - -@EnableJpaAuditing -@Configuration -class JpaConfig \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompany/Accompany.kt b/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompany/Accompany.kt deleted file mode 100644 index 835e1a8..0000000 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompany/Accompany.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.travel.withaeng.domain.accompany - -import com.travel.withaeng.domain.BaseEntity -import jakarta.persistence.* -import java.time.LocalDate - -@Table(name = "accompany") -@Entity -class Accompany( - @Column(name = "user_id", nullable = false) - val userId: Long, - - @Column(name = "title", nullable = false) - val title: String, - - @Lob - @Column(name = "content", nullable = false) - val content: String, - - @Embedded - val destination: Destination, - - @Column(name = "start_trip_date", nullable = false) - val startTripDate: LocalDate, - - @Column(name = "end_trip_date", nullable = false) - val endTripDate: LocalDate, - - @Column(name = "banner_image_url") - val bannerImageUrl: String?, - - @Column(name = "view_counts", nullable = false) - val viewCounts: Long = 0L -) : BaseEntity() \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompany/AccompanyDto.kt b/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompany/AccompanyDto.kt deleted file mode 100644 index 7efbc8a..0000000 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompany/AccompanyDto.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.travel.withaeng.domain.accompany - -import java.time.LocalDate -import java.time.LocalDateTime - -data class AccompanyDto( - val userId: Long, - val title: String, - val content: String, - val destination: Destination, - val startTripDate: LocalDate, - val endTripDate: LocalDate, - val bannerImageUrl: String?, - val viewCounts: Long, - val createdAt: LocalDateTime -) - -fun Accompany.toDto(): AccompanyDto = AccompanyDto( - userId = userId, - title = title, - content = content, - destination = destination, - startTripDate = startTripDate, - endTripDate = endTripDate, - bannerImageUrl = bannerImageUrl, - viewCounts = viewCounts, - createdAt = createdAt -) \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompany/Destination.kt b/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompany/Destination.kt deleted file mode 100644 index 0db3414..0000000 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompany/Destination.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.travel.withaeng.domain.accompany - -import jakarta.persistence.Column -import jakarta.persistence.Embeddable - -@Embeddable -data class Destination( - @Column(name = "continent", nullable = false) - val continent: String, - - @Column(name = "country", nullable = false) - val country: String, - - @Column(name = "city", nullable = false) - val city: String -) \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanylike/AccompanyLike.kt b/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanylike/AccompanyLike.kt deleted file mode 100644 index 12792d2..0000000 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanylike/AccompanyLike.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.travel.withaeng.domain.accompanylike - -import com.travel.withaeng.domain.BaseEntity -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Table - -@Table(name = "accompany_like") -@Entity -class AccompanyLike( - @Column(name = "user_id", nullable = false) - val userId: Long, - - @Column(name = "accompany_id", nullable = false) - val accompanyId: Long -) : BaseEntity() \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanyreply/AccompanyReply.kt b/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanyreply/AccompanyReply.kt deleted file mode 100644 index 986d58d..0000000 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanyreply/AccompanyReply.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.travel.withaeng.domain.accompanyreply - -import com.travel.withaeng.domain.BaseEntity -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Table - -@Table(name = "accompany_reply") -@Entity -class AccompanyReply( - @Column(name = "accompany_id", nullable = false) - val accompanyId: Long, - - @Column(name = "user_id", nullable = false) - val userId: Long, - - @Column(name = "content", nullable = false) - val content: String, - - @Column(name = "depth", nullable = false) - val depth: Long, - - @Column(name = "group_id", nullable = false) - val groupId: Long -) : BaseEntity() \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanyreply/AccompanyReplyDto.kt b/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanyreply/AccompanyReplyDto.kt deleted file mode 100644 index db7e4d5..0000000 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanyreply/AccompanyReplyDto.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.travel.withaeng.domain.accompanyreply - -data class AccompanyReplyDto( - val accompanyId: Long, - val userId: Long, - val content: String, - val depth: Long, - val groupId: Long -) - -fun AccompanyReply.toDto(): AccompanyReplyDto = AccompanyReplyDto( - accompanyId = accompanyId, - userId = userId, - content = content, - depth = depth, - groupId = groupId -) \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/user/UserEntity.kt b/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/user/UserEntity.kt deleted file mode 100644 index 369472b..0000000 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/user/UserEntity.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.travel.withaeng.domain.user - -import com.travel.withaeng.domain.BaseEntity -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Table -import java.time.LocalDate - -@Table(name = "users") -@Entity -class UserEntity( - @Column(name = "nickname", nullable = false) - val nickname: String, - - @Column(name = "birth", nullable = false) - val birth: LocalDate, - - @Column(name = "is_male", nullable = false) - val isMale: Boolean, - - @Column(name = "profile_image_url") - val profileImageUrl: String? = null, - - @Column(name = "bio") - val bio: String? = null -) : BaseEntity() \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/BaseEntity.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/BaseEntity.kt similarity index 96% rename from withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/BaseEntity.kt rename to withaeng-domain/src/main/kotlin/com/withaeng/domain/BaseEntity.kt index 1d2586b..e7cc93f 100644 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/BaseEntity.kt +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/BaseEntity.kt @@ -1,4 +1,4 @@ -package com.travel.withaeng.domain +package com.withaeng.domain import jakarta.persistence.* import org.springframework.data.annotation.CreatedDate diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/WithaengDomainModule.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/WithaengDomainModule.kt new file mode 100644 index 0000000..163424a --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/WithaengDomainModule.kt @@ -0,0 +1,3 @@ +package com.withaeng.domain + +interface WithaengDomainModule \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/Accompany.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/Accompany.kt new file mode 100644 index 0000000..efd70a3 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/Accompany.kt @@ -0,0 +1,125 @@ +package com.withaeng.domain.accompany + +import com.withaeng.domain.BaseEntity +import com.withaeng.domain.accompany.dto.CreateAccompanyDto +import com.withaeng.domain.accompanyrequests.AccompanyJoinRequest +import com.withaeng.domain.accompanystatistics.AccompanyStatistics +import com.withaeng.domain.converter.AccompanyTagsConverter +import jakarta.persistence.* +import org.hibernate.annotations.Comment +import org.hibernate.annotations.DynamicUpdate +import java.time.LocalDate + +@Entity +@DynamicUpdate +@Table(name = "accompany") +class Accompany( + + @Column(name = "user_id", nullable = false) + val userId: Long, + + @Column(name = "title", nullable = false) + @Comment("동행 제목") + var title: String, + + @Lob + @Column(name = "content", nullable = false) + @Comment("동행 내용") + var content: String, + + @Enumerated(EnumType.STRING) + @Column(name = "accompany_status", nullable = false) + @Comment("동행 모집 상태") + var accompanyStatus: AccompanyStatus = AccompanyStatus.ING, + + @Column(name = "start_trip_date", nullable = false) + @Comment("여행 시작 일자") + var startTripDate: LocalDate, + + @Column(name = "end_trip_date", nullable = false) + @Comment("여행 종료 일자") + var endTripDate: LocalDate, + + @Column(name = "banner_image_url") + @Comment("베너 이미지 URI") + var bannerImageUrl: String?, + + @Column(name = "member_count", nullable = false) + @Comment("모집 인원") + var memberCount: Long = 0L, + + @Column(name = "open_kakao_url", nullable = false) + @Comment("오픈 카카오 채팅 URI") + var openKakaoUrl: String, + + @Embedded + @Comment("동행 장소 정보") + var accompanyDestination: AccompanyDestination, + + @Column(name = "start_accompany_age", nullable = false) + @Comment("동행 시작 연령") + var startAccompanyAge: Int, + + @Column(name = "end_accompany_age", nullable = false) + @Comment("동행 종료 연령") + var endAccompanyAge: Int, + + @Enumerated(EnumType.STRING) + @Column(name = "prefer_gender", nullable = false) + @Comment("동행 선호 성별") + var preferGender: AccompanyPreferGender, + + @Convert(converter = AccompanyTagsConverter::class) + @Column(name = "tags", nullable = false) + @Comment("태그 목록") + var tags: Set = setOf(), + + @OneToOne(mappedBy = "accompany", cascade = [CascadeType.ALL], orphanRemoval = true) + var accompanyStatistics: AccompanyStatistics? = null, + + @OneToMany(mappedBy = "accompany", cascade = [CascadeType.ALL], orphanRemoval = true) + var joinRequests: MutableList = mutableListOf(), + + ) : BaseEntity() { + + fun increaseViewCount() { + this.accompanyStatistics?.increaseViewCount() + } + + fun update( + content: String?, + tags: Set?, + ) { + this.content = content ?: this.content + this.tags = tags ?: this.tags + } + + fun updateStatusToComplete() { + this.accompanyStatus = AccompanyStatus.COMPLETE + } + + fun isCompleted() = + this.accompanyStatus == AccompanyStatus.COMPLETE + + companion object { + fun create(params: CreateAccompanyDto): Accompany { + val accompany = Accompany( + userId = params.userId, + title = params.title, + content = params.content, + startTripDate = params.startTripDate, + endTripDate = params.endTripDate, + bannerImageUrl = params.bannerImageUrl, + memberCount = params.memberCount, + openKakaoUrl = params.openKakaoUrl, + accompanyDestination = params.destination, + startAccompanyAge = params.startAccompanyAge.value, + endAccompanyAge = params.endAccompanyAge.value, + preferGender = params.preferGender, + tags = params.tags ?: setOf(), + ) + accompany.accompanyStatistics = AccompanyStatistics(accompany = accompany) + return accompany + } + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyAge.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyAge.kt new file mode 100644 index 0000000..9de163b --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyAge.kt @@ -0,0 +1,40 @@ +package com.withaeng.domain.accompany + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType + +enum class AccompanyAge( + val code: String, + val value: Int, +) { + MIN("MIN", 0), + TWENTY("TWENTY", 20), + TWENTY_FIVE("TWENTYFIVE", 25), + THIRTY("THIRTY", 30), + THIRTY_FIVE("THIRTYFIVE", 35), + FORTY("FORTY", 40), + FORTY_FIVE("FORTYFIVE", 45), + OVER_FIFTY("FIFTY", 50), + MAX("MAX", 99) + ; + + companion object { + fun fromValue(value: Int): AccompanyAge { + return AccompanyAge.values().find { it.value == value } + ?: throw WithaengException.of( + type = WithaengExceptionType.INVALID_ACCOMPANY_AGE_VALUE, + message = "$value is not a valid value for $this" + ) + } + } +} + +class AccompanyAgeDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): AccompanyAge { + val value = p.intValue + return AccompanyAge.fromValue(value) + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyDestination.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyDestination.kt new file mode 100644 index 0000000..a3978c2 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyDestination.kt @@ -0,0 +1,28 @@ +package com.withaeng.domain.accompany + +import com.withaeng.domain.destination.City +import com.withaeng.domain.destination.Continent +import com.withaeng.domain.destination.Country +import jakarta.persistence.Column +import jakarta.persistence.Embeddable +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import org.hibernate.annotations.Comment + +@Embeddable +data class AccompanyDestination( + @Enumerated(EnumType.STRING) + @Column(name = "continent") + @Comment("대륙") + val continent: Continent, + + @Enumerated(EnumType.STRING) + @Column(name = "country") + @Comment("국가") + val country: Country, + + @Enumerated(EnumType.STRING) + @Column(name = "city") + @Comment("도시") + val city: City, +) \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyPreferGender.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyPreferGender.kt new file mode 100644 index 0000000..55b2292 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyPreferGender.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.accompany + +enum class AccompanyPreferGender { + MALE, FEMALE, NO_PREFERENCE +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyRepository.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyRepository.kt new file mode 100644 index 0000000..97f44da --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyRepository.kt @@ -0,0 +1,8 @@ +package com.withaeng.domain.accompany + +import org.springframework.data.jpa.repository.JpaRepository + +interface AccompanyRepository : JpaRepository, AccompanyRepositoryCustom { + + fun countByUserId(userId: Long): Int +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyRepositoryCustom.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyRepositoryCustom.kt new file mode 100644 index 0000000..f719ca7 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyRepositoryCustom.kt @@ -0,0 +1,13 @@ +package com.withaeng.domain.accompany + +import com.withaeng.domain.accompany.dto.FindAccompanyDto +import com.withaeng.domain.accompany.dto.SearchAccompanyDto +import com.withaeng.domain.accompany.dto.SearchAccompanyQuery +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface AccompanyRepositoryCustom { + fun findAccompanyDetail(accompanyId: Long): FindAccompanyDto? + + fun searchAccompanies(pageable: Pageable, query: SearchAccompanyQuery): Page +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyRepositoryImpl.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyRepositoryImpl.kt new file mode 100644 index 0000000..497d127 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyRepositoryImpl.kt @@ -0,0 +1,205 @@ +package com.withaeng.domain.accompany + +import com.querydsl.core.types.OrderSpecifier +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.impl.JPAQueryFactory +import com.withaeng.domain.accompany.QAccompany.accompany +import com.withaeng.domain.accompany.dto.* +import com.withaeng.domain.accompanylike.QAccompanyLike.accompanyLike +import com.withaeng.domain.accompanyrequests.AccompanyJoinRequestStatus +import com.withaeng.domain.accompanyrequests.QAccompanyJoinRequest.accompanyJoinRequest +import com.withaeng.domain.accompanystatistics.QAccompanyStatistics.accompanyStatistics +import com.withaeng.domain.destination.City +import com.withaeng.domain.destination.Continent +import com.withaeng.domain.destination.Country +import com.withaeng.domain.user.QUser.user +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.support.PageableExecutionUtils +import java.time.LocalDate + +class AccompanyRepositoryImpl( + + private val queryFactory: JPAQueryFactory, + + ) : AccompanyRepositoryCustom { + + override fun findAccompanyDetail(accompanyId: Long): FindAccompanyDto? { + return queryFactory + .select( + QFindAccompanyDto( + accompany.id, + accompany.userId, + accompany.title, + accompany.content, + accompany.accompanyDestination, + accompany.startTripDate, + accompany.endTripDate, + accompany.bannerImageUrl, + accompany.memberCount, + accompanyStatistics.viewCount, + accompany.openKakaoUrl, + accompany.startAccompanyAge, + accompany.endAccompanyAge, + accompany.preferGender, + accompany.tags, + accompanyLike.count(), + QFindAccompanyUserInfoDto( + user.profile.nickname, + user.profile.profileImageUrl, + user.gender, + user.profile.introduction, + user.createdAt, + ), + ) + ) + .from(accompany) + .leftJoin(accompanyLike) + .on(accompany.id.eq(accompanyLike.accompanyId)) + .innerJoin(accompanyStatistics) + .on(accompany.id.eq(accompanyStatistics.accompany.id)) + .innerJoin(user) + .on(accompany.userId.eq(user.id)) + .where( + accompanyIdEq(accompanyId) + ) + .groupBy(accompanyLike.accompanyId) + .fetchOne() + } + + override fun searchAccompanies(pageable: Pageable, query: SearchAccompanyQuery): Page { + val result = queryFactory + .select( + QSearchAccompanyDto( + accompany.id, + accompany.bannerImageUrl, + accompany.accompanyStatus, + accompany.startTripDate, + accompany.endTripDate, + accompanyJoinRequest.count(), + accompany.memberCount, + accompany.title, + accompany.tags, + QSearchAccompanyHostDto( + user.id, + user.profile.nickname, + user.profile.profileImageUrl, + ), + ) + ) + .from(accompany) + .innerJoin(user).on(accompany.userId.eq(user.id)) + .innerJoin(accompanyStatistics).on(accompany.id.eq(accompanyStatistics.accompany.id)) + .leftJoin(accompanyJoinRequest).on( + accompanyJoinRequest.accompany.eq(accompany) + .and(accompanyJoinRequest.status.eq(AccompanyJoinRequestStatus.ACCEPT)) + ) + .groupBy( + accompany.id, + accompany.bannerImageUrl, + accompany.accompanyStatus, + user.id, + user.profile.nickname, + user.profile.profileImageUrl, + accompany.startTripDate, + accompany.endTripDate, + accompany.memberCount, + accompany.title, + accompany.tags + ) + .where( + statusEq(query.status), + continentEq(query.continent), + countryEq(query.country), + cityEq(query.city), + containTripDate(query.startDate, query.endDate), + containMemberCount(query.minMemberCount, query.maxMemberCount), + containAllowedAge(query.minAllowedAge, query.maxAllowedAge), + preferGenderEq(query.preferGender), + ) + .orderBy(*orderSpecifiers(query.sort)) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .fetch() + + val countQuery = queryFactory + .select(accompany.id.count()) + .from(accompany) + .innerJoin(user) + .on(accompany.userId.eq(user.id)) + .leftJoin(accompanyJoinRequest).on( + accompanyJoinRequest.accompany.eq(accompany) + .and(accompanyJoinRequest.status.eq(AccompanyJoinRequestStatus.ACCEPT)) + ) + .where( + continentEq(query.continent), + countryEq(query.country), + cityEq(query.city), + containTripDate(query.startDate, query.endDate), + containMemberCount(query.minMemberCount, query.maxMemberCount), + containAllowedAge(query.minAllowedAge, query.maxAllowedAge), + preferGenderEq(query.preferGender), + ) + + return PageableExecutionUtils.getPage(result, pageable) { countQuery.fetchOne() ?: 0L } + } + + private fun orderSpecifiers(sort: AccompanySort?): Array> { + return when (sort) { + AccompanySort.RECENT -> arrayOf( + accompany.createdAt.desc(), + accompanyStatistics.viewCount.desc(), + ) + + AccompanySort.POPULAR -> arrayOf( + accompanyStatistics.viewCount.desc(), + accompany.createdAt.desc(), + ) + + else -> arrayOf( + accompany.createdAt.desc(), + accompanyStatistics.viewCount.desc(), + ) + } + } + + + private fun statusEq(status: AccompanyStatus?): BooleanExpression? { + return status?.let { accompany.accompanyStatus.eq(it) } + } + + private fun continentEq(continent: Continent?): BooleanExpression? { + return continent?.let { accompany.accompanyDestination.continent.eq(it) } + } + + private fun countryEq(country: Country?): BooleanExpression? { + return country?.let { accompany.accompanyDestination.country.eq(it) } + } + + private fun cityEq(city: City?): BooleanExpression? { + return city?.let { accompany.accompanyDestination.city.eq(it) } + } + + private fun containTripDate(startDate: LocalDate?, endDate: LocalDate?): BooleanExpression? { + return startDate?.let { accompany.endTripDate.goe(startDate) } + ?.and(endDate?.let { accompany.startTripDate.loe(endDate) }) + } + + private fun containMemberCount(minMemberCount: Int?, maxMemberCount: Int?): BooleanExpression? { + return minMemberCount?.let { accompany.memberCount.goe(minMemberCount) } + ?.and(maxMemberCount?.let { accompany.memberCount.loe(maxMemberCount) }) + } + + private fun containAllowedAge(minAllowedAge: AccompanyAge?, maxAllowedAge: AccompanyAge?): BooleanExpression? { + return minAllowedAge?.let { accompany.endAccompanyAge.goe(minAllowedAge.value) } + ?.and(maxAllowedAge?.let { accompany.startAccompanyAge.loe(maxAllowedAge.value) }) + } + + private fun preferGenderEq(preferGender: AccompanyPreferGender?): BooleanExpression? { + return preferGender?.let { accompany.preferGender.eq(it) } + } + + private fun accompanyIdEq(accompanyId: Long): BooleanExpression { + return accompany.id.eq(accompanyId) + } +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyService.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyService.kt new file mode 100644 index 0000000..42a9a01 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyService.kt @@ -0,0 +1,83 @@ +package com.withaeng.domain.accompany + +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.accompany.dto.* +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AccompanyService( + private val accompanyRepository: AccompanyRepository, +) { + + companion object { + private const val NOT_EXIST_MESSAGE = "해당하는 동행을 찾을 수 없습니다." + } + + @Transactional + fun create(params: CreateAccompanyDto): AccompanyDto { + val accompanyEntity = Accompany.create(params) + accompanyRepository.save(accompanyEntity) + return accompanyEntity.toDto() + } + + @Transactional + fun update(params: UpdateAccompanyDto): AccompanyDto { + val accompany = accompanyRepository.findByIdOrNull(params.accompanyId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = NOT_EXIST_MESSAGE + ) + accompany.update( + content = params.content, + tags = params.tags, + ) + + return accompany.toDto() + } + + @Transactional(readOnly = true) + fun search(pageable: Pageable, query: SearchAccompanyQuery): Page { + return accompanyRepository.searchAccompanies(pageable, query) + } + + @Transactional(readOnly = true) + fun detail(accompanyId: Long): FindAccompanyDto { + return accompanyRepository.findAccompanyDetail(accompanyId) + ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = NOT_EXIST_MESSAGE + ) + } + + @Transactional + fun increaseViewCount(accompanyId: Long) { + val accompany = accompanyRepository.findByIdOrNull(accompanyId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = NOT_EXIST_MESSAGE + ) + accompany.increaseViewCount() + } + + @Transactional(readOnly = true) + fun findAll(): List { + return accompanyRepository.findAll().map { it.toDto() } + } + + @Transactional(readOnly = true) + fun countByUserId(userId: Long): Int { + return accompanyRepository.countByUserId(userId) + } + + @Transactional(readOnly = true) + fun findById(accompanyId: Long): AccompanyDto { + val accompany = accompanyRepository.findByIdOrNull(accompanyId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = NOT_EXIST_MESSAGE + ) + return accompany.toDto() + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanySort.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanySort.kt new file mode 100644 index 0000000..143e634 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanySort.kt @@ -0,0 +1,6 @@ +package com.withaeng.domain.accompany + +enum class AccompanySort { + RECENT, + POPULAR, +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyStatus.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyStatus.kt new file mode 100644 index 0000000..aa460f7 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/AccompanyStatus.kt @@ -0,0 +1,6 @@ +package com.withaeng.domain.accompany + +enum class AccompanyStatus(val statusDescription: String) { + ING("동행 구인중"), + COMPLETE("동행 모집 완료"), +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/dto/AccompanyDto.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/dto/AccompanyDto.kt new file mode 100644 index 0000000..77d0657 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/dto/AccompanyDto.kt @@ -0,0 +1,118 @@ +package com.withaeng.domain.accompany.dto + +import com.querydsl.core.annotations.QueryProjection +import com.withaeng.domain.accompany.* +import com.withaeng.domain.user.Gender +import java.time.LocalDate +import java.time.LocalDateTime + +data class CreateAccompanyDto( + val userId: Long, + val title: String, + val content: String, + val destination: AccompanyDestination, + val startTripDate: LocalDate, + val endTripDate: LocalDate, + val bannerImageUrl: String? = null, + val memberCount: Long, + val tags: Set? = emptySet(), + val openKakaoUrl: String, + val startAccompanyAge: AccompanyAge, + val endAccompanyAge: AccompanyAge, + val preferGender: AccompanyPreferGender, +) + +data class UpdateAccompanyDto( + val accompanyId: Long, + val content: String? = null, + val tags: Set? = null, +) + +data class AccompanyDto( + val id: Long, + val userId: Long, + val title: String, + val content: String, + val accompanyStatus: AccompanyStatus, + val destination: AccompanyDestination, + val startTripDate: LocalDate, + val endTripDate: LocalDate, + val bannerImageUrl: String? = null, + val memberCount: Long, + val viewCount: Long, + val openKakaoUrl: String, + val startAccompanyAge: AccompanyAge, + val endAccompanyAge: AccompanyAge, + val preferGender: AccompanyPreferGender, + val tags: Set? = emptySet(), +) + +fun Accompany.toDto(): AccompanyDto = AccompanyDto( + id = id, + userId = userId, + title = title, + content = content, + accompanyStatus = accompanyStatus, + destination = accompanyDestination, + startTripDate = startTripDate, + endTripDate = endTripDate, + bannerImageUrl = bannerImageUrl, + memberCount = memberCount, + viewCount = accompanyStatistics?.viewCount ?: 0, + openKakaoUrl = openKakaoUrl, + startAccompanyAge = AccompanyAge.fromValue(startAccompanyAge), + endAccompanyAge = AccompanyAge.fromValue(endAccompanyAge), + preferGender = preferGender, + tags = tags, +) + +data class SearchAccompanyDto @QueryProjection constructor( + val id: Long, + val bannerImageUrl: String?, + val status: AccompanyStatus, + val startDate: LocalDate, + val endDate: LocalDate, + val currentMemberCount: Long, + val maxMemberCount: Long, + val title: String, + val tags: Set? = emptySet(), + val host: SearchAccompanyHostDto, +) + +data class SearchAccompanyHostDto @QueryProjection constructor( + val id: Long, + val nickname: String, + val profileImageUrl: String?, +) + + +data class FindAccompanyDto @QueryProjection constructor( + val id: Long, + val userId: Long, + val title: String, + val content: String, + val destination: AccompanyDestination, + val startTripDate: LocalDate, + val endTripDate: LocalDate, + val bannerImageUrl: String? = null, + val memberCount: Long, + val viewCount: Long, + val openKakaoUrl: String, + val startAccompanyAge: Int, + val endAccompanyAge: Int, + val preferGender: AccompanyPreferGender, + val tags: Set? = emptySet(), + val likeCount: Long = 0, + val author: FindAccompanyUserInfoDto, +) + +data class FindAccompanyUserInfoDto @QueryProjection constructor( + val nickname: String, + val profileImageUrl: String?, + val gender: Gender, + val introduction: String?, + val joinDate: LocalDateTime, + // TODO : 여행 관심사 Set 추가 필요 + // TODO : 온도 추가 필요 + // TODO : 연령대 추가 필요 +) \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/dto/AccompanyQuery.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/dto/AccompanyQuery.kt new file mode 100644 index 0000000..f348d28 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompany/dto/AccompanyQuery.kt @@ -0,0 +1,25 @@ +package com.withaeng.domain.accompany.dto + +import com.withaeng.domain.accompany.AccompanyAge +import com.withaeng.domain.accompany.AccompanyPreferGender +import com.withaeng.domain.accompany.AccompanySort +import com.withaeng.domain.accompany.AccompanyStatus +import com.withaeng.domain.destination.City +import com.withaeng.domain.destination.Continent +import com.withaeng.domain.destination.Country +import java.time.LocalDate + +data class SearchAccompanyQuery( + val sort: AccompanySort? = null, + val status: AccompanyStatus? = null, + val continent: Continent? = null, + val country: Country? = null, + val city: City? = null, + val startDate: LocalDate? = null, + val endDate: LocalDate? = null, + val minMemberCount: Int? = null, + val maxMemberCount: Int? = null, + val minAllowedAge: AccompanyAge? = null, + val maxAllowedAge: AccompanyAge? = null, + val preferGender: AccompanyPreferGender? = null, +) \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestRepository.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestRepository.kt new file mode 100644 index 0000000..3e633f0 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestRepository.kt @@ -0,0 +1,17 @@ +package com.withaeng.domain.accompanyjoinrequests + +import com.withaeng.domain.accompanyrequests.AccompanyJoinRequest +import com.withaeng.domain.accompanyrequests.AccompanyJoinRequestStatus +import org.springframework.data.jpa.repository.JpaRepository + +interface AccompanyJoinRequestRepository : JpaRepository, + AccompanyJoinRequestRepositoryCustom { + fun countByAccompanyIdAndStatus( + accompanyId: Long, + status: AccompanyJoinRequestStatus = AccompanyJoinRequestStatus.ACCEPT, + ): Int + + fun existsByAccompanyIdAndUserId(accompanyId: Long, userId: Long): Boolean + + fun findByAccompanyId(accompanyId: Long): List +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestRepositoryCustom.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestRepositoryCustom.kt new file mode 100644 index 0000000..4e0bc01 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestRepositoryCustom.kt @@ -0,0 +1,7 @@ +package com.withaeng.domain.accompanyjoinrequests + +import com.withaeng.domain.accompanyjoinrequests.dto.FindAccompanyJoinRequestDto + +interface AccompanyJoinRequestRepositoryCustom { + fun findJoinRequestsByAccompanyId(accompanyId: Long): List +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestRepositoryImpl.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestRepositoryImpl.kt new file mode 100644 index 0000000..a7427b4 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestRepositoryImpl.kt @@ -0,0 +1,48 @@ +package com.withaeng.domain.accompanyjoinrequests + +import com.querydsl.core.types.dsl.CaseBuilder +import com.querydsl.jpa.impl.JPAQueryFactory +import com.withaeng.domain.accompanyjoinrequests.dto.FindAccompanyJoinRequestDto +import com.withaeng.domain.accompanyjoinrequests.dto.QFindAccompanyJoinRequestDto +import com.withaeng.domain.accompanyrequests.AccompanyJoinRequestStatus +import com.withaeng.domain.accompanyrequests.QAccompanyJoinRequest.accompanyJoinRequest +import com.withaeng.domain.user.QUser.user + + +class AccompanyJoinRequestRepositoryImpl( + + private val queryFactory: JPAQueryFactory, + + ) : AccompanyJoinRequestRepositoryCustom { + + override fun findJoinRequestsByAccompanyId(accompanyId: Long): List { + return queryFactory + .select( + QFindAccompanyJoinRequestDto( + accompanyJoinRequest.id, + accompanyJoinRequest.status, + user.profile.nickname, + user.profile.profileImageUrl, + user.gender, + user.profile.introduction, + user.createdAt, + ) + ) + .from(accompanyJoinRequest) + .innerJoin(user) + .on(accompanyJoinRequest.userId.eq(user.id)) + .where( + accompanyJoinRequest.accompany.id.eq(accompanyId), + accompanyJoinRequest.status.notIn(AccompanyJoinRequestStatus.CANCEL) + ) + .orderBy(findJoinRequestsSort()) + .fetch() + } + + private fun findJoinRequestsSort() = CaseBuilder() + .`when`(accompanyJoinRequest.status.eq(AccompanyJoinRequestStatus.WAIT)).then(1) + .`when`(accompanyJoinRequest.status.eq(AccompanyJoinRequestStatus.ACCEPT)).then(2) + .`when`(accompanyJoinRequest.status.eq(AccompanyJoinRequestStatus.REJECT)).then(3) + .otherwise(4) + .asc() +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestService.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestService.kt new file mode 100644 index 0000000..187bb7b --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/AccompanyJoinRequestService.kt @@ -0,0 +1,128 @@ +package com.withaeng.domain.accompanyjoinrequests + +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.accompany.Accompany +import com.withaeng.domain.accompany.AccompanyRepository +import com.withaeng.domain.accompanyjoinrequests.dto.AccompanyJoinRequestDto +import com.withaeng.domain.accompanyjoinrequests.dto.toDto +import com.withaeng.domain.accompanyrequests.AccompanyJoinRequest +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AccompanyJoinRequestService( + private val accompanyJoinRequestRepository: AccompanyJoinRequestRepository, + private val accompanyRepository: AccompanyRepository, +) { + + @Transactional + fun create(accompanyId: Long, userId: Long) { + val accompany = findAccompanyByIdOrNull(accompanyId) + validateFull(accompany, accompanyId) + validateDuplicateJoin(accompanyId, userId) + val accompanyJoinRequest = AccompanyJoinRequest.create(userId, accompany) + accompanyJoinRequestRepository.save(accompanyJoinRequest) + } + + @Transactional + fun cancelJoin(accompanyId: Long, joinRequestId: Long) { + val accompany = findAccompanyByIdOrNull(accompanyId) + validateCompletedAccompany(accompany) + val accompanyJoinRequest = findAccompanyJoinRequestByIdOrNull(joinRequestId) + accompanyJoinRequest.cancel() + } + + @Transactional + fun acceptJoin(accompanyId: Long, joinRequestId: Long) { + val accompanyJoinRequest = findAccompanyJoinRequestByIdOrNull(joinRequestId) + val accompany = findAccompanyByIdOrNull(accompanyId) + validateFull(accompany, accompanyId) + accompanyJoinRequest.accept() + + if (isBelowMemberLimit(accompany, accompanyId)) { + accompany.updateStatusToComplete() + } + } + + @Transactional + fun rejectJoin(accompanyId: Long, joinRequestId: Long) { + val accompanyJoinRequest = findAccompanyJoinRequestByIdOrNull(joinRequestId) + val accompany = findAccompanyByIdOrNull(accompanyId) + validateFull(accompany, accompanyId) + validateNotWaitingJoinRequest(accompanyJoinRequest) + accompanyJoinRequest.reject() + } + + @Transactional(readOnly = true) + fun findById(joinRequestId: Long): AccompanyJoinRequestDto { + val accompanyJoinRequest = findAccompanyJoinRequestByIdOrNull(joinRequestId) + return accompanyJoinRequest.toDto() + } + + private fun findAccompanyByIdOrNull(accompanyId: Long) = + accompanyRepository.findByIdOrNull(accompanyId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "해당하는 동행을 찾을 수 없습니다." + ) + + private fun findAccompanyJoinRequestByIdOrNull(joinRequestId: Long) = + accompanyJoinRequestRepository.findByIdOrNull(joinRequestId) + ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "동행 신청 정보가 존재하지 않습니다." + ) + + private fun getAcceptedJoinRequestCount(accompanyId: Long) = + accompanyJoinRequestRepository.countByAccompanyIdAndStatus(accompanyId) + + private fun existsJoinRequestByUser(accompanyId: Long, userId: Long) = + accompanyJoinRequestRepository.existsByAccompanyIdAndUserId(accompanyId, userId) + + private fun validateNotWaitingJoinRequest(accompanyJoinRequest: AccompanyJoinRequest) { + if (accompanyJoinRequest.isNotWaiting()) { + throw WithaengException.of( + type = WithaengExceptionType.INVALID_ACCESS, + message = "승인 대기 상태가 아닙니다." + ) + } + } + + private fun validateCompletedAccompany(accompany: Accompany) { + if (accompany.isCompleted()) { + throw WithaengException.of( + type = WithaengExceptionType.INVALID_ACCESS, + message = "모집 마감된 동행은 취소할 수 없습니다." + ) + } + } + + private fun validateFull(accompany: Accompany, accompanyId: Long) { + if (isFull(accompany, accompanyId)) { + throw WithaengException.of( + type = WithaengExceptionType.INVALID_ACCESS, + message = "모집 마감되었습니다." + ) + } + } + + private fun validateDuplicateJoin(accompanyId: Long, userId: Long) { + if (existsJoinRequestByUser(accompanyId, userId)) { + throw WithaengException.of( + type = WithaengExceptionType.ALREADY_EXIST, + message = "동행 신청한 내역이 존재합니다." + ) + } + } + + private fun isFull(accompany: Accompany, accompanyId: Long) = + accompany.isCompleted() || isBelowMemberLimit(accompany, accompanyId) + + private fun isBelowMemberLimit(accompany: Accompany, accompanyId: Long) = + accompany.memberCount <= getAcceptedJoinRequestCount(accompanyId) + 1 + + @Transactional(readOnly = true) + fun findJoinRequestsByAccompanyId(accompanyId: Long) = + accompanyJoinRequestRepository.findJoinRequestsByAccompanyId(accompanyId) +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/dto/AccompanyJoinRequestDto.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/dto/AccompanyJoinRequestDto.kt new file mode 100644 index 0000000..1f43e64 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyjoinrequests/dto/AccompanyJoinRequestDto.kt @@ -0,0 +1,34 @@ +package com.withaeng.domain.accompanyjoinrequests.dto + +import com.querydsl.core.annotations.QueryProjection +import com.withaeng.domain.accompanyrequests.AccompanyJoinRequest +import com.withaeng.domain.accompanyrequests.AccompanyJoinRequestStatus +import com.withaeng.domain.user.Gender +import java.time.LocalDateTime + +data class AccompanyJoinRequestDto( + val id: Long, + val userId: Long, + val accompanyJoinRequestStatus: AccompanyJoinRequestStatus, + val accompanyId: Long, +) + +fun AccompanyJoinRequest.toDto() = AccompanyJoinRequestDto( + id = id, + userId = userId, + accompanyJoinRequestStatus = status, + accompanyId = accompany.id, +) + +data class FindAccompanyJoinRequestDto @QueryProjection constructor( + val joinRequestId: Long, + val joinRequestStatus: AccompanyJoinRequestStatus, + val nickname: String, + val profileImageUrl: String?, + val gender: Gender, + val introduction: String?, + val joinDate: LocalDateTime, + // TODO : 여행 관심사 Set 추가 필요 + // TODO : 온도 추가 필요 + // TODO : 연령대 추가 필요 +) \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanylike/AccompanyLike.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanylike/AccompanyLike.kt new file mode 100644 index 0000000..0a75117 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanylike/AccompanyLike.kt @@ -0,0 +1,25 @@ +package com.withaeng.domain.accompanylike + +import com.withaeng.domain.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table + +@Table(name = "accompany_like") +@Entity +class AccompanyLike( + + @Column(name = "accompany_id", nullable = false) + val accompanyId: Long, + + @Column(name = "user_id", nullable = false) + val userId: Long + +) : BaseEntity() { + + companion object { + fun create(userId: Long, accompanyId: Long): AccompanyLike { + return AccompanyLike(accompanyId = accompanyId, userId = userId) + } + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanylike/AccompanyLikeRepository.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanylike/AccompanyLikeRepository.kt new file mode 100644 index 0000000..1d25f49 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanylike/AccompanyLikeRepository.kt @@ -0,0 +1,8 @@ +package com.withaeng.domain.accompanylike + +import org.springframework.data.jpa.repository.JpaRepository + +interface AccompanyLikeRepository : JpaRepository { + fun countByAccompanyId(accompanyId: Long): Long + fun findByUserIdAndAccompanyId(userId: Long, accompanyId: Long): AccompanyLike? +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanylike/AccompanyLikeService.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanylike/AccompanyLikeService.kt new file mode 100644 index 0000000..81d2b1e --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanylike/AccompanyLikeService.kt @@ -0,0 +1,47 @@ +package com.withaeng.domain.accompanylike + +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.accompany.AccompanyRepository +import com.withaeng.domain.user.UserRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class AccompanyLikeService( + private val userRepository: UserRepository, + private val accompanyRepository: AccompanyRepository, + private val accompanyLikeRepository: AccompanyLikeRepository +) { + + @Transactional + fun createAccompanyLike(userId: Long, accompanyId: Long) { + userRepository.findByIdOrNull(userId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "해당하는 유저를 찾을 수 없습니다." + ) + accompanyRepository.findByIdOrNull(accompanyId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "해당하는 동행을 찾을 수 없습니다." + ) + val prevAccompanyLike = accompanyLikeRepository.findByUserIdAndAccompanyId(userId, accompanyId) + if (prevAccompanyLike != null) return + val accompanyLike = AccompanyLike.create(userId = userId, accompanyId = accompanyId) + accompanyLikeRepository.save(accompanyLike) + } + + fun countByAccompanyId(accompanyId: Long): Long { + return accompanyLikeRepository.countByAccompanyId(accompanyId) + } + + @Transactional + fun deleteAccompanyLike(userId: Long, accompanyId: Long) { + val accompanyLike = accompanyLikeRepository.findByUserIdAndAccompanyId( + userId = userId, + accompanyId = accompanyId + ) ?: return + accompanyLikeRepository.delete(accompanyLike) + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReply.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReply.kt new file mode 100644 index 0000000..a131952 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReply.kt @@ -0,0 +1,53 @@ +package com.withaeng.domain.accompanyreply + +import com.withaeng.domain.BaseEntity +import jakarta.persistence.* +import org.hibernate.annotations.DynamicUpdate +import java.time.LocalDateTime + +@DynamicUpdate +@Table(name = "accompany_reply") +@Entity +class AccompanyReply( + + @Column(name = "accompany_id", nullable = false) + val accompanyId: Long, + + @Column(name = "parent_id", nullable = true) + val parentId: Long? = null, + + @Column(name = "user_id", nullable = false) + val userId: Long, + + @Column(name = "content", nullable = false) + var content: String, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: AccompanyReplyStatus, + + ) : BaseEntity() { + companion object { + fun create(accompanyId: Long, userId: Long, content: String, parentId: Long? = null): AccompanyReply { + return AccompanyReply( + accompanyId = accompanyId, + userId = userId, + content = content, + parentId = parentId, + status = AccompanyReplyStatus.ACTIVE + ) + } + } + + fun update( + newContent: String + ) { + this.content = newContent + } + + fun delete() { + this.deletedAt = LocalDateTime.now() + this.status = AccompanyReplyStatus.DELETED + } + +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyDto.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyDto.kt new file mode 100644 index 0000000..61154b9 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyDto.kt @@ -0,0 +1,26 @@ +package com.withaeng.domain.accompanyreply + +import com.querydsl.core.annotations.QueryProjection +import java.time.LocalDateTime + +data class AccompanyReplyDto @QueryProjection constructor( + val id: Long, + val userId: Long, + val accompanyId: Long, + val parentId: Long? = null, + val content: String?, + val createdAt: LocalDateTime?, + val status: AccompanyReplyStatus, + val count: Long, +) + +fun AccompanyReply.toDto(): AccompanyReplyDto = AccompanyReplyDto( + id = id, + userId = userId, + accompanyId = accompanyId, + parentId = parentId, + content = content, + createdAt = createdAt, + status = status, + count = 0 +) diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyRepository.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyRepository.kt new file mode 100644 index 0000000..0bdcee8 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyRepository.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.accompanyreply + +import org.springframework.data.jpa.repository.JpaRepository + +interface AccompanyReplyRepository : JpaRepository, AccompanyReplyRepositoryCustom \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyRepositoryCustom.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyRepositoryCustom.kt new file mode 100644 index 0000000..ad1a205 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyRepositoryCustom.kt @@ -0,0 +1,8 @@ +package com.withaeng.domain.accompanyreply + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface AccompanyReplyRepositoryCustom { + fun findAccompanyReplyList(accompanyId: Long, pageable: Pageable): Page +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyRepositoryImpl.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyRepositoryImpl.kt new file mode 100644 index 0000000..c1b5dab --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyRepositoryImpl.kt @@ -0,0 +1,66 @@ +package com.withaeng.domain.accompanyreply + +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.impl.JPAQueryFactory +import com.withaeng.domain.accompanyreply.QAccompanyReply.accompanyReply +import com.withaeng.domain.accompanyreplylike.QAccompanyReplyLike.accompanyReplyLike +import com.withaeng.domain.user.QUser.user +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.support.PageableExecutionUtils + +class AccompanyReplyRepositoryImpl( + + private val queryFactory: JPAQueryFactory, + + ) : AccompanyReplyRepositoryCustom { + + override fun findAccompanyReplyList( + accompanyId: Long, + pageable: Pageable, + ): Page { + + val contents = queryFactory + .select( + QFindAccompanyReplyDto( + accompanyReply.id, + accompanyReply.accompanyId, + accompanyReply.parentId, + accompanyReply.content, + accompanyReply.status, + accompanyReplyLike.count(), + accompanyReply.createdAt, + QFindAccompanyReplyUserInfoDto( + user.id, + user.email, + user.profile.nickname, + ) + ) + ) + .from(accompanyReply) + .leftJoin(accompanyReplyLike) + .on(accompanyReply.id.eq(accompanyReplyLike.replyId)) + .innerJoin(user) + .on(accompanyReply.userId.eq(user.id)) + .where( + accompanyIdEq(accompanyId) + ) + .groupBy(accompanyReply.id) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .fetch() + + val totalCount = queryFactory + .select(accompanyReply.count()) + .from(accompanyReply) + .where( + accompanyIdEq(accompanyId) + ) + + return PageableExecutionUtils.getPage(contents, pageable) { totalCount.fetchOne() ?: 0L } + } + + private fun accompanyIdEq(accompanyId: Long): BooleanExpression? { + return accompanyReply.accompanyId.eq(accompanyId) + } +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyService.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyService.kt new file mode 100644 index 0000000..0d9b37b --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyService.kt @@ -0,0 +1,96 @@ +package com.withaeng.domain.accompanyreply + +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.accompany.AccompanyRepository +import com.withaeng.domain.user.UserRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class AccompanyReplyService( + private val userRepository: UserRepository, + private val accompanyRepository: AccompanyRepository, + private val accompanyReplyRepository: AccompanyReplyRepository +) { + + @Transactional + fun create(accompanyId: Long, userId: Long, content: String, parentId: Long? = null): AccompanyReplyDto { + validateCreateAccompanyReply(accompanyId, userId) + if (parentId != null) { + validateNotExistsParentId(parentId) + } + val accompanyReply = AccompanyReply.create( + accompanyId = accompanyId, userId = userId, content = content, parentId = parentId + ) + accompanyReplyRepository.save(accompanyReply) + return accompanyReply.toDto() + } + + fun findById(replyId: Long): AccompanyReplyDto { + val accompanyReply = accompanyReplyRepository.findByIdOrNull(replyId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, message = "해당하는 댓글을 찾을 수 없습니다." + ) + return accompanyReply.toDto() + } + + fun getList(accompanyId: Long, pageable: Pageable): Page { + return accompanyReplyRepository.findAccompanyReplyList(accompanyId, pageable).map { + it.takeIf { it.status == AccompanyReplyStatus.DELETED }?.copy( + content = null, + createdAt = null, + ) ?: it + } + } + + @Transactional + fun update(replyId: Long, content: String): AccompanyReplyDto { + val accompanyReply = accompanyReplyRepository.findByIdOrNull(replyId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, message = "해당하는 댓글을 찾을 수 없습니다." + ) + accompanyReply.update(content) + return accompanyReply.toDto() + } + + @Transactional + fun delete(replyId: Long) { + val accompanyReply = accompanyReplyRepository.findByIdOrNull(replyId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, message = "해당하는 댓글을 찾을 수 없습니다." + ) + accompanyReply.delete() + } + + private fun validateCreateAccompanyReply(accompanyId: Long, userId: Long) { + validateExistsUser(userId) + validateExistsAccompany(accompanyId) + } + + private fun validateExistsAccompany(accompanyId: Long) { + if (!accompanyRepository.existsById(accompanyId)) { + throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, message = "해당하는 동행을 찾을 수 없습니다." + ) + } + } + + private fun validateExistsUser(userId: Long) { + if (!userRepository.existsById(userId)) { + throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, message = "해당하는 유저를 찾을 수 없습니다." + ) + } + } + + private fun validateNotExistsParentId(parentId: Long) { + if (accompanyReplyRepository.existsById(parentId)) { + throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, message = "해당하는 댓글을 찾을 수 없습니다." + ) + } + } + +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyStatus.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyStatus.kt new file mode 100644 index 0000000..6fd1155 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/AccompanyReplyStatus.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.accompanyreply + +enum class AccompanyReplyStatus { + ACTIVE, DELETED +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/FindAccompanyReplyDto.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/FindAccompanyReplyDto.kt new file mode 100644 index 0000000..fb90a90 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/FindAccompanyReplyDto.kt @@ -0,0 +1,15 @@ +package com.withaeng.domain.accompanyreply + +import com.querydsl.core.annotations.QueryProjection +import java.time.LocalDateTime + +data class FindAccompanyReplyDto @QueryProjection constructor( + val id: Long, + val accompanyId: Long, + val parentId: Long? = null, + val content: String?, + val status: AccompanyReplyStatus, + val likeCount: Long = 0, + val createdAt: LocalDateTime?, + val author: FindAccompanyReplyUserInfoDto, +) diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/FindAccompanyReplyUserInfoDto.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/FindAccompanyReplyUserInfoDto.kt new file mode 100644 index 0000000..df217ca --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreply/FindAccompanyReplyUserInfoDto.kt @@ -0,0 +1,10 @@ +package com.withaeng.domain.accompanyreply + +import com.querydsl.core.annotations.QueryProjection + +data class FindAccompanyReplyUserInfoDto @QueryProjection constructor( + val id: Long, + val email: String, + val nickname: String, +) + diff --git a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanyreplylike/AccompanyReplyLike.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreplylike/AccompanyReplyLike.kt similarity index 57% rename from withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanyreplylike/AccompanyReplyLike.kt rename to withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreplylike/AccompanyReplyLike.kt index b42ed26..f6b7a73 100644 --- a/withaeng-domain/src/main/kotlin/com/travel/withaeng/domain/accompanyreplylike/AccompanyReplyLike.kt +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreplylike/AccompanyReplyLike.kt @@ -1,6 +1,6 @@ -package com.travel.withaeng.domain.accompanyreplylike +package com.withaeng.domain.accompanyreplylike -import com.travel.withaeng.domain.BaseEntity +import com.withaeng.domain.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.Table @@ -8,9 +8,11 @@ import jakarta.persistence.Table @Table(name = "accompany_reply_like") @Entity class AccompanyReplyLike( - @Column(name = "accompany_reply_id", nullable = false) - val accompanyReplyId: Long, + + @Column(name = "reply_id", nullable = false) + val replyId: Long, @Column(name = "user_id", nullable = false) val userId: Long + ) : BaseEntity() \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreplylike/AccompanyReplyLikeRepository.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreplylike/AccompanyReplyLikeRepository.kt new file mode 100644 index 0000000..4ea57ba --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreplylike/AccompanyReplyLikeRepository.kt @@ -0,0 +1,8 @@ +package com.withaeng.domain.accompanyreplylike + +import org.springframework.data.jpa.repository.JpaRepository + +interface AccompanyReplyLikeRepository : JpaRepository { + fun countByReplyId(replyId: Long): Long + fun findByUserIdAndReplyId(userId: Long, replyId: Long): AccompanyReplyLike? +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreplylike/AccompanyReplyLikeService.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreplylike/AccompanyReplyLikeService.kt new file mode 100644 index 0000000..f8ace60 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyreplylike/AccompanyReplyLikeService.kt @@ -0,0 +1,50 @@ +package com.withaeng.domain.accompanyreplylike + +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.accompanyreply.AccompanyReplyRepository +import com.withaeng.domain.user.UserRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class AccompanyReplyLikeService( + private val userRepository: UserRepository, + private val accompanyReplyRepository: AccompanyReplyRepository, + private val accompanyReplyLikeRepository: AccompanyReplyLikeRepository, +) { + + @Transactional + fun createAccompanyReplyLike(userId: Long, accompanyReplyId: Long) { + userRepository.findByIdOrNull(userId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "해당하는 유저가 없습니다." + ) + accompanyReplyRepository.findByIdOrNull(accompanyReplyId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "해당하는 댓글이 없습니다." + ) + if (accompanyReplyLikeRepository.findByUserIdAndReplyId(userId, accompanyReplyId) != null) return + accompanyReplyLikeRepository.save( + AccompanyReplyLike( + replyId = accompanyReplyId, + userId = userId + ) + ) + } + + fun countAccompanyReplyLikeCount(accompanyReplyId: Long): Long { + return accompanyReplyLikeRepository.countByReplyId(accompanyReplyId) + } + + @Transactional + fun deleteAccompanyReplyLike(userId: Long, accompanyReplyId: Long) { + val accompanyReplyLike = accompanyReplyLikeRepository.findByUserIdAndReplyId( + userId = userId, + replyId = accompanyReplyId + ) ?: return + accompanyReplyLikeRepository.delete(accompanyReplyLike) + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyrequests/AccompanyJoinRequest.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyrequests/AccompanyJoinRequest.kt new file mode 100644 index 0000000..0224707 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyrequests/AccompanyJoinRequest.kt @@ -0,0 +1,46 @@ +package com.withaeng.domain.accompanyrequests + +import com.withaeng.domain.BaseEntity +import com.withaeng.domain.accompany.Accompany +import jakarta.persistence.* + +@Entity +@Table(name = "accompany_join_request") +class AccompanyJoinRequest( + + @Column(name = "user_id", nullable = false) + val userId: Long, + + @Enumerated(EnumType.STRING) + var status: AccompanyJoinRequestStatus, + + @ManyToOne + @JoinColumn(name = "accompany_id") + val accompany: Accompany, +) : BaseEntity() { + + companion object { + fun create(userId: Long, accompany: Accompany): AccompanyJoinRequest { + return AccompanyJoinRequest( + userId = userId, + status = AccompanyJoinRequestStatus.WAIT, + accompany = accompany, + ) + } + } + + fun cancel() { + status = AccompanyJoinRequestStatus.CANCEL + } + + fun accept() { + status = AccompanyJoinRequestStatus.ACCEPT + } + + fun reject() { + status = AccompanyJoinRequestStatus.REJECT + } + + fun isNotWaiting() = + this.status != AccompanyJoinRequestStatus.WAIT +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyrequests/AccompanyJoinRequestStatus.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyrequests/AccompanyJoinRequestStatus.kt new file mode 100644 index 0000000..c228d6d --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanyrequests/AccompanyJoinRequestStatus.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.accompanyrequests + +enum class AccompanyJoinRequestStatus { + WAIT, ACCEPT, REJECT, CANCEL +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanystatistics/AccompanyStatistics.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanystatistics/AccompanyStatistics.kt new file mode 100644 index 0000000..b53a2dd --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/accompanystatistics/AccompanyStatistics.kt @@ -0,0 +1,28 @@ +package com.withaeng.domain.accompanystatistics + +import com.withaeng.domain.accompany.Accompany +import jakarta.persistence.* +import org.hibernate.annotations.Comment + +@Entity +@Table(name = "accompany_statistics") +class AccompanyStatistics( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + val id: Long = 0L, + + @OneToOne + @JoinColumn(name = "accompany_id", nullable = false) + val accompany: Accompany, + + @Column(name = "view_count", nullable = false) + @Comment("조회수") + var viewCount: Long = 0L, +) { + + fun increaseViewCount() { + this.viewCount++ + } + +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/config/JpaConfig.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/config/JpaConfig.kt new file mode 100644 index 0000000..0fb66d0 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/config/JpaConfig.kt @@ -0,0 +1,13 @@ +package com.withaeng.domain.config + +import com.withaeng.domain.WithaengDomainModule +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import org.springframework.data.jpa.repository.config.EnableJpaRepositories + +@EnableJpaAuditing +@Configuration +@EntityScan(basePackageClasses = [WithaengDomainModule::class]) +@EnableJpaRepositories(basePackageClasses = [WithaengDomainModule::class]) +class JpaConfig \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/config/JpaQueryDslConfig.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/config/JpaQueryDslConfig.kt new file mode 100644 index 0000000..6f965e4 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/config/JpaQueryDslConfig.kt @@ -0,0 +1,19 @@ +package com.withaeng.domain.config + +import com.querydsl.jpa.impl.JPAQueryFactory +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JpaQueryDslConfig { + + @PersistenceContext + lateinit var entityManager: EntityManager + + @Bean + fun jpaQueryFactory(): JPAQueryFactory { + return JPAQueryFactory(entityManager) + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/AccompanyTagsConverter.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/AccompanyTagsConverter.kt new file mode 100644 index 0000000..c1a6d57 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/AccompanyTagsConverter.kt @@ -0,0 +1,19 @@ +package com.withaeng.domain.converter + +import jakarta.persistence.AttributeConverter + +class AccompanyTagsConverter : AttributeConverter, String> { + override fun convertToDatabaseColumn(attribute: Set): String { + return attribute + .filter { it.isNotBlank() } + .joinToString(DELIMITER) + } + + override fun convertToEntityAttribute(data: String): Set { + return if (data.isBlank()) emptySet() else data.split(DELIMITER).toSet() + } + + companion object { + private const val DELIMITER = "," + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserFoodRestrictionConverter.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserFoodRestrictionConverter.kt new file mode 100644 index 0000000..2bd9ad4 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserFoodRestrictionConverter.kt @@ -0,0 +1,19 @@ +package com.withaeng.domain.converter + +import com.withaeng.domain.user.UserFoodRestriction +import jakarta.persistence.AttributeConverter + +class UserFoodRestrictionConverter : AttributeConverter, String> { + override fun convertToDatabaseColumn(attribute: Set): String { + return attribute.joinToString(DELIMITER) + } + + override fun convertToEntityAttribute(data: String): Set { + return if (data.isBlank()) emptySet() + else data.split(DELIMITER).map { UserFoodRestriction.valueOf(it.trim()) }.toSet() + } + + companion object { + private const val DELIMITER = "," + } +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserMbtiConverter.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserMbtiConverter.kt new file mode 100644 index 0000000..d229f6a --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserMbtiConverter.kt @@ -0,0 +1,19 @@ +package com.withaeng.domain.converter + +import com.withaeng.domain.user.UserMbti +import jakarta.persistence.AttributeConverter + +class UserMbtiConverter : AttributeConverter, String> { + override fun convertToDatabaseColumn(attribute: Set): String { + return attribute.joinToString(DELIMITER) { it.name } + } + + override fun convertToEntityAttribute(data: String): Set { + return if (data.isBlank()) return emptySet() + else data.split(DELIMITER).map { UserMbti.valueOf(it.trim()) }.toSet() + } + + companion object { + private const val DELIMITER = "," + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserPreferTravelThemeConverter.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserPreferTravelThemeConverter.kt new file mode 100644 index 0000000..989c3cf --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserPreferTravelThemeConverter.kt @@ -0,0 +1,19 @@ +package com.withaeng.domain.converter + +import com.withaeng.domain.user.UserPreferTravelTheme +import jakarta.persistence.AttributeConverter + +class UserPreferTravelThemeConverter : AttributeConverter, String> { + override fun convertToDatabaseColumn(attribute: Set): String { + return attribute.joinToString(DELIMITER) { it.name } + } + + override fun convertToEntityAttribute(data: String): Set { + return if (data.isBlank()) return emptySet() + else data.split(DELIMITER).map { UserPreferTravelTheme.valueOf(it.trim()) }.toSet() + } + + companion object { + private const val DELIMITER = "," + } +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserRoleConverter.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserRoleConverter.kt new file mode 100644 index 0000000..ed1f128 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/converter/UserRoleConverter.kt @@ -0,0 +1,19 @@ +package com.withaeng.domain.converter + +import com.withaeng.domain.user.UserRole +import jakarta.persistence.AttributeConverter + +class UserRoleConverter : AttributeConverter, String> { + override fun convertToDatabaseColumn(attribute: Set): String { + return attribute.joinToString(DELIMITER) { it.name } + } + + override fun convertToEntityAttribute(data: String): Set { + return if (data.isBlank()) return emptySet() + else data.split(DELIMITER).map { UserRole.valueOf(it.trim()) }.toSet() + } + + companion object { + private const val DELIMITER = "," + } +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/destination/City.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/destination/City.kt new file mode 100644 index 0000000..d12e7ae --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/destination/City.kt @@ -0,0 +1,179 @@ +package com.withaeng.domain.destination + +enum class City( + val countryCode: String, + val cityCode: String, + val cityName: String, +) { + + SEOUL(Country.KOREA.countryCode, "SEOUL", "서울"), + BUSAN(Country.KOREA.countryCode, "BUSAN", "부산"), + INCHEON(Country.KOREA.countryCode, "INCHEON", "인천"), + DAEGU(Country.KOREA.countryCode, "DAEGU", "대구"), + GWANGJU(Country.KOREA.countryCode, "GWANGJU", "광주"), + DAEJEON(Country.KOREA.countryCode, "DAEJEON", "대전"), + ULSAN(Country.KOREA.countryCode, "ULSAN", "울산"), + SUWON(Country.KOREA.countryCode, "SUWON", "수원"), + CHANGWON(Country.KOREA.countryCode, "CHANGWON", "창원"), + JEJU(Country.KOREA.countryCode, "JEJU", "제주"), + + // Japan + TOKYO(Country.JAPAN.countryCode, "TOKYO", "도쿄"), + OSAKA(Country.JAPAN.countryCode, "OSAKA", "오사카"), + KYOTO(Country.JAPAN.countryCode, "KYOTO", "교토"), + SAPPORO(Country.JAPAN.countryCode, "SAPPORO", "삿포로"), + FUKUOKA(Country.JAPAN.countryCode, "FUKUOKA", "후쿠오카"), + HIROSHIMA(Country.JAPAN.countryCode, "HIROSHIMA", "히로시마"), + NAGOYA(Country.JAPAN.countryCode, "NAGOYA", "나고야"), + OKINAWA(Country.JAPAN.countryCode, "OKINAWA", "오키나와"), + KANAZAWA(Country.JAPAN.countryCode, "KANAZAWA", "가나자와"), + + // China + BEIJING(Country.CHINA.countryCode, "BEIJING", "베이징"), + SHANGHAI(Country.CHINA.countryCode, "SHANGHAI", "상하이"), + HONG_KONG(Country.CHINA.countryCode, "HONG_KONG", "홍콩"), + MACAO(Country.CHINA.countryCode, "MACAO", "마카오"), + GUANGZHOU(Country.CHINA.countryCode, "GUANGZHOU", "광저우"), + SHENZHEN(Country.CHINA.countryCode, "SHENZHEN", "선전"), + CHENGDU(Country.CHINA.countryCode, "CHENGDU", "청두"), + XIAMEN(Country.CHINA.countryCode, "XIAMEN", "샤먼"), + TIANJIN(Country.CHINA.countryCode, "TIANJIN", "천진"), + + // United States + NEW_YORK(Country.UNITED_STATES.countryCode, "NEW_YORK", "뉴욕"), + LOS_ANGELES(Country.UNITED_STATES.countryCode, "LOS_ANGELES", "로스앤젤레스"), + CHICAGO(Country.UNITED_STATES.countryCode, "CHICAGO", "시카고"), + LAS_VEGAS(Country.UNITED_STATES.countryCode, "LAS_VEGAS", "라스베이거스"), + SAN_FRANCISCO(Country.UNITED_STATES.countryCode, "SAN_FRANCISCO", "샌프란시스코"), + WASHINGTON_DC(Country.UNITED_STATES.countryCode, "WASHINGTON_DC", "워싱턴 D.C."), + MIAMI(Country.UNITED_STATES.countryCode, "MIAMI", "마이애미"), + ORLANDO(Country.UNITED_STATES.countryCode, "ORLANDO", "올랜도"), + SEATTLE(Country.UNITED_STATES.countryCode, "SEATTLE", "시애틀"), + + // United Kingdom + LONDON(Country.ENGLAND.countryCode, "LONDON", "런던"), + EDINBURGH(Country.ENGLAND.countryCode, "EDINBURGH", "에든버러"), + MANCHESTER(Country.ENGLAND.countryCode, "MANCHESTER", "맨체스터"), + BIRMINGHAM(Country.ENGLAND.countryCode, "BIRMINGHAM", "버밍엄"), + GLASGOW(Country.ENGLAND.countryCode, "GLASGOW", "글래스고"), + LIVERPOOL(Country.ENGLAND.countryCode, "LIVERPOOL", "리버풀"), + CAMBRIDGE(Country.ENGLAND.countryCode, "CAMBRIDGE", "케임브리지"), + + // France + PARIS(Country.FRANCE.countryCode, "PARIS", "파리"), + MARSEILLE(Country.FRANCE.countryCode, "MARSEILLE", "마르세유"), + LYON(Country.FRANCE.countryCode, "LYON", "리옹"), + NICE(Country.FRANCE.countryCode, "NICE", "니스"), + BORDEAUX(Country.FRANCE.countryCode, "BORDEAUX", "보르도"), + STRASBOURG(Country.FRANCE.countryCode, "STRASBOURG", "스트라스부르"), + + // Germany + BERLIN(Country.GERMANY.countryCode, "BERLIN", "베를린"), + MUNICH(Country.GERMANY.countryCode, "MUNICH", "뮌헨"), + FRANKFURT(Country.GERMANY.countryCode, "FRANKFURT", "프랑크푸르트"), + HAMBURG(Country.GERMANY.countryCode, "HAMBURG", "함부르크"), + COLOGNE(Country.GERMANY.countryCode, "COLOGNE", "쾰른"), + DUSSELDORF(Country.GERMANY.countryCode, "DUSSELDORF", "뒤셀도르프"), + + // Italy + ROME(Country.ITALY.countryCode, "ROME", "로마"), + MILAN(Country.ITALY.countryCode, "MILAN", "밀라노"), + VENICE(Country.ITALY.countryCode, "VENICE", "베네치아"), + FLORENCE(Country.ITALY.countryCode, "FLORENCE", "피렌체"), + NAPLES(Country.ITALY.countryCode, "NAPLES", "나폴리"), + TURIN(Country.ITALY.countryCode, "TURIN", "투린"), + + // Spain + MADRID(Country.SPAIN.countryCode, "MADRID", "마드리드"), + BARCELONA(Country.SPAIN.countryCode, "BARCELONA", "바르셀로나"), + VALENCIA(Country.SPAIN.countryCode, "VALENCIA", "발렌시아"), + SEVILLE(Country.SPAIN.countryCode, "SEVILLE", "세비야"), + MALAGA(Country.SPAIN.countryCode, "MALAGA", "말라가"), + BILBAO(Country.SPAIN.countryCode, "BILBAO", "빌바오"), + + // Canada + TORONTO(Country.CANADA.countryCode, "TORONTO", "토론토"), + VANCOUVER(Country.CANADA.countryCode, "VANCOUVER", "밴쿠버"), + MONTREAL(Country.CANADA.countryCode, "MONTREAL", "몬트리올"), + CALGARY(Country.CANADA.countryCode, "CALGARY", "캘거리"), + OTTAWA(Country.CANADA.countryCode, "OTTAWA", "오타와"), + QUEBEC_CITY(Country.CANADA.countryCode, "QUEBEC_CITY", "퀘벡 시티"), + + // Australia + SYDNEY(Country.AUSTRALIA.countryCode, "SYDNEY", "시드니"), + MELBOURNE(Country.AUSTRALIA.countryCode, "MELBOURNE", "멜버른"), + BRISBANE(Country.AUSTRALIA.countryCode, "BRISBANE", "브리즈번"), + PERTH(Country.AUSTRALIA.countryCode, "PERTH", "퍼스"), + ADELAIDE(Country.AUSTRALIA.countryCode, "ADELAIDE", "애들레이드"), + CANBERRA(Country.AUSTRALIA.countryCode, "CANBERRA", "캔버라"), + + // Brazil + SAO_PAULO(Country.BRAZIL.countryCode, "SAO_PAULO", "상파울로"), + RIO_DE_JANEIRO(Country.BRAZIL.countryCode, "RIO_DE_JANEIRO", "리오 데 자네이로"), + BRASILIA(Country.BRAZIL.countryCode, "BRASILIA", "브라질리아"), + SALVADOR(Country.BRAZIL.countryCode, "SALVADOR", "살바도르"), + BELO_HORIZONTE(Country.BRAZIL.countryCode, "BELO_HORIZONTE", "벨루 오리손티"), + + // India + MUMBAI(Country.INDIA.countryCode, "MUMBAI", "뭄바이"), + NEW_DELHI(Country.INDIA.countryCode, "NEW_DELHI", "뉴델리"), + BANGALORE(Country.INDIA.countryCode, "BANGALORE", "방갈로르"), + CHENNAI(Country.INDIA.countryCode, "CHENNAI", "첸나이"), + KOLKATA(Country.INDIA.countryCode, "KOLKATA", "콜카타"), + HYDERABAD(Country.INDIA.countryCode, "HYDERABAD", "하이데라바드"), + + MOSCOW(Country.RUSSIA.countryCode, "MOSCOW", "모스크바"), + SAINT_PETERSBURG(Country.RUSSIA.countryCode, "SAINT_PETERSBURG", "상트페테르부르크"), + NOVOSIBIRSK(Country.RUSSIA.countryCode, "NOVOSIBIRSK", "노보시비르스크"), + EKATERINBURG(Country.RUSSIA.countryCode, "EKATERINBURG", "예카테린부르크"), + KAZAN(Country.RUSSIA.countryCode, "KAZAN", "카잔"), + NIZHNY_NOVGOROD(Country.RUSSIA.countryCode, "NIZHNY_NOVGOROD", "니즈니 노브고로드"), + + // Turkey + ISTANBUL(Country.TURKIYE.countryCode, "ISTANBUL", "이스탄불"), + ANKARA(Country.TURKIYE.countryCode, "ANKARA", "앙카라"), + IZMIR(Country.TURKIYE.countryCode, "IZMIR", "이즈미르"), + BURSA(Country.TURKIYE.countryCode, "BURSA", "부르사"), + ANTALYA(Country.TURKIYE.countryCode, "ANTALYA", "안탈리아"), + ADANA(Country.TURKIYE.countryCode, "ADANA", "아다나"), + + // Argentina + BUENOS_AIRES(Country.ARGENTINA.countryCode, "BUENOS_AIRES", "부에노스 아이레스"), + CORDOBA(Country.ARGENTINA.countryCode, "CORDOBA", "코르도바"), + ROSARIO(Country.ARGENTINA.countryCode, "ROSARIO", "로사리오"), + MENDOZA(Country.ARGENTINA.countryCode, "MENDOZA", "멘도사"), + SALTA(Country.ARGENTINA.countryCode, "SALTA", "살타"), + + // Egypt + CAIRO(Country.EGYPT.countryCode, "CAIRO", "카이로"), + ALEXANDRIA(Country.EGYPT.countryCode, "ALEXANDRIA", "알렉산드리아"), + LUXOR(Country.EGYPT.countryCode, "LUXOR", "룩소르"), + SHARM_EL_SHEIKH(Country.EGYPT.countryCode, "SHARM_EL_SHEIKH", "샤름 엘 쉬크"), + ASWAN(Country.EGYPT.countryCode, "ASWAN", "아스완"), + HURGHADA(Country.EGYPT.countryCode, "HURGHADA", "후르가다"), + + // Thailand + BANGKOK(Country.THAILAND.countryCode, "BANGKOK", "방콕"), + PHUKET(Country.THAILAND.countryCode, "PHUKET", "푸켓"), + CHIANG_MAI(Country.THAILAND.countryCode, "CHIANG_MAI", "치앙마이"), + PATTAYA(Country.THAILAND.countryCode, "PATTAYA", "파타야"), + KRABI(Country.THAILAND.countryCode, "KRABI", "크라비"), + HUA_HIN(Country.THAILAND.countryCode, "HUA_HIN", "후아힌"), + + // Mexico + MEXICO_CITY(Country.MEXICO.countryCode, "MEXICO_CITY", "멕시코시티"), + CANCUN(Country.MEXICO.countryCode, "CANCUN", "칸쿤"), + GUADALAJARA(Country.MEXICO.countryCode, "GUADALAJARA", "과달라하라"), + MONTERREY(Country.MEXICO.countryCode, "MONTERREY", "몬테레이"), + TULUM(Country.MEXICO.countryCode, "TULUM", "툴룸"), + OAXACA(Country.MEXICO.countryCode, "OAXACA", "오아하카"), + + // Indonesia + JAKARTA(Country.INDONESIA.countryCode, "JAKARTA", "자카르타"), + BALI(Country.INDONESIA.countryCode, "BALI", "발리"), + BANDUNG(Country.INDONESIA.countryCode, "BANDUNG", "반둥"), + YOGYAKARTA(Country.INDONESIA.countryCode, "YOGYAKARTA", "요가야카르타"), + SURABAYA(Country.INDONESIA.countryCode, "SURABAYA", "수라바야"), + MAKASSAR(Country.INDONESIA.countryCode, "MAKASSAR", "마카사르"); + +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/destination/Continent.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/destination/Continent.kt new file mode 100644 index 0000000..3c15be8 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/destination/Continent.kt @@ -0,0 +1,18 @@ +package com.withaeng.domain.destination + +enum class Continent( + val continentCode: String, + val continentName: String, +) { + + EAST_ASIA("EA", "동아시아"), + SOUTHEAST_ASIA("SA", "동남아시아"), + CENTRAL_ASIA("CA", "중앙아시아"), + WESTERN_ASIA("WA", "서남아시아"), + EUROPE("EU", "유럽"), + OCEANIA("OC", "오세아니아"), + AFRICA("AF", "아프리카"), + NORTH_AMERICA("NA", "북아메리카"), + SOUTH_AMERICA("SA", "남아메리카") + +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/destination/Country.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/destination/Country.kt new file mode 100644 index 0000000..7f2da51 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/destination/Country.kt @@ -0,0 +1,189 @@ +package com.withaeng.domain.destination + +enum class Country( + val continentCode: String, + val countryCode: String, + val countryName: String, +) { + + KOREA(Continent.EAST_ASIA.continentCode, "KOREA", "한국"), + JAPAN(Continent.EAST_ASIA.continentCode, "JAPAN", "일본"), + HONG_KONG(Continent.EAST_ASIA.continentCode, "HONG_KONG", "홍콩"), + MACAO(Continent.EAST_ASIA.continentCode, "MACAO", "마카오"), + TAIWAN(Continent.EAST_ASIA.continentCode, "TAIWAN", "대만"), + CHINA(Continent.EAST_ASIA.continentCode, "CHINA", "중국"), + MONGOLIA(Continent.EAST_ASIA.continentCode, "MONGOLIA", "몽골"), + + SINGAPORE(Continent.SOUTHEAST_ASIA.continentCode, "SINGAPORE", "싱가포르"), + EAST_TIMOR(Continent.SOUTHEAST_ASIA.continentCode, "EAST_TIMOR", "동티모르"), + MYANMAR(Continent.SOUTHEAST_ASIA.continentCode, "MYANMAR", "미얀마"), + CAMBODIA(Continent.SOUTHEAST_ASIA.continentCode, "CAMBODIA", "캄보디아"), + LAOS(Continent.SOUTHEAST_ASIA.continentCode, "LAOS", "라오스"), + PHILIPPINES(Continent.SOUTHEAST_ASIA.continentCode, "PHILIPPINES", "필리핀"), + MALAYSIA(Continent.SOUTHEAST_ASIA.continentCode, "MALAYSIA", "말레이시아"), + INDONESIA(Continent.SOUTHEAST_ASIA.continentCode, "INDONESIA", "인도네시아"), + THAILAND(Continent.SOUTHEAST_ASIA.continentCode, "THAILAND", "태국"), + VIETNAM(Continent.SOUTHEAST_ASIA.continentCode, "VIETNAM", "베트남"), + BRUNEI(Continent.SOUTHEAST_ASIA.continentCode, "BRUNEI", "브루나이"), + + UZBEKISTAN(Continent.CENTRAL_ASIA.continentCode, "UZBEKISTAN", "우즈베키스탄"), + BANGLADESH(Continent.CENTRAL_ASIA.continentCode, "BANGLADESH", "방글라데시"), + AZERBAIJAN(Continent.CENTRAL_ASIA.continentCode, "AZERBAIJAN", "아제르바이잔"), + BUTANE(Continent.CENTRAL_ASIA.continentCode, "BUTANE", "부탄"), + AFGHANISTAN(Continent.CENTRAL_ASIA.continentCode, "AFGHANISTAN", "아프가니스탄"), + TAJIKISTAN(Continent.CENTRAL_ASIA.continentCode, "TAJIKISTAN", "타지키스탄"), + KYRGYZSTAN(Continent.CENTRAL_ASIA.continentCode, "KYRGYZSTAN", "키르기스스탄"), + KAZAKHSTAN(Continent.CENTRAL_ASIA.continentCode, "KAZAKHSTAN", "카자흐스탄"), + TURKMENISTAN(Continent.CENTRAL_ASIA.continentCode, "TURKMENISTAN", "투르크메니스탄"), + TIBET(Continent.CENTRAL_ASIA.continentCode, "TIBET", "티베트"), + + ARAB_EMIRATES(Continent.WESTERN_ASIA.continentCode, "ARAB_EMIRATES", "아랍에미리트"), + JORDAN(Continent.WESTERN_ASIA.continentCode, "JORDAN", "요르단"), + YEMEN(Continent.WESTERN_ASIA.continentCode, "YEMEN", "예멘"), + SYRIA(Continent.WESTERN_ASIA.continentCode, "SYRIA", "시리아"), + IRAN(Continent.WESTERN_ASIA.continentCode, "IRAN", "이란"), + PAKISTAN(Continent.WESTERN_ASIA.continentCode, "PAKISTAN", "파키스탄"), + CYPRUS(Continent.WESTERN_ASIA.continentCode, "CYPRUS", "키프로스"), + SRI_LANKA(Continent.WESTERN_ASIA.continentCode, "SRI_LANKA", "스리랑카"), + MALDIVE(Continent.WESTERN_ASIA.continentCode, "MALDIVE", "몰디브"), + BAHRAIN(Continent.WESTERN_ASIA.continentCode, "BAHRAIN", "바레인"), + LRAQ(Continent.WESTERN_ASIA.continentCode, "LRAQ", "이라크"), + PALESTINE(Continent.WESTERN_ASIA.continentCode, "PALESTINE", "팔레스타인"), + OMAN(Continent.WESTERN_ASIA.continentCode, "OMAN", "오만"), + INDIA(Continent.WESTERN_ASIA.continentCode, "INDIA", "인도"), + NEPAL(Continent.WESTERN_ASIA.continentCode, "NEPAL", "네팔"), + ISRAEL(Continent.WESTERN_ASIA.continentCode, "ISRAEL", "이스라엘"), + CATARRH(Continent.WESTERN_ASIA.continentCode, "CATARRH", "카타르"), + LEBANON(Continent.WESTERN_ASIA.continentCode, "LEBANON", "레바논"), + SAUDI_ARABIA(Continent.WESTERN_ASIA.continentCode, "SAUDI_ARABIA", "사우디아라비아"), + KUWAIT(Continent.WESTERN_ASIA.continentCode, "KUWAIT", "쿠웨이트"), + ARMENIA(Continent.WESTERN_ASIA.continentCode, "ARMENIA", "아르메니아"), + + GEORGIA(Continent.EUROPE.continentCode, "GEORGIA", "조지아"), + MALTA(Continent.EUROPE.continentCode, "MALTA", "몰타"), + MOLDOVA(Continent.EUROPE.continentCode, "MOLDOVA", "몰도바"), + MONTENEGRO(Continent.EUROPE.continentCode, "MONTENEGRO", "몬테네그로"), + MONACO(Continent.EUROPE.continentCode, "MONACO", "모나코"), + MACEDONIA(Continent.EUROPE.continentCode, "MACEDONIA", "마케도니아"), + LIECHTENSTEIN(Continent.EUROPE.continentCode, "LIECHTENSTEIN", "리히텐슈타인"), + LITHUANIA(Continent.EUROPE.continentCode, "LITHUANIA", "리투아니아"), + LUXEMBOURG(Continent.EUROPE.continentCode, "LUXEMBOURG", "룩셈부르크"), + ROMANIA(Continent.EUROPE.continentCode, "ROMANIA", "루마니아"), + VATICAN(Continent.EUROPE.continentCode, "VATICAN", "바티칸"), + BELARUS(Continent.EUROPE.continentCode, "BELARUS", "벨라루스"), + BOSNIA_HERCEGOVINA(Continent.EUROPE.continentCode, "BOSNIA_HERCEGOVINA", "보스니아헤르체코비나"), + CROATIA(Continent.EUROPE.continentCode, "CROATIA", "크로아티아"), + UKRAINE(Continent.EUROPE.continentCode, "UKRAINE", "우크라이나"), + ESTONIA(Continent.EUROPE.continentCode, "ESTONIA", "에스토니아"), + ALBANIA(Continent.EUROPE.continentCode, "ALBANIA", "알바니아"), + ANDORRA(Continent.EUROPE.continentCode, "ANDORRA", "안도라"), + SLOVAKIA(Continent.EUROPE.continentCode, "SLOVAKIA", "슬로바키아"), + SERBIA(Continent.EUROPE.continentCode, "SERBIA", "세르비아"), + SAN_MARINO(Continent.EUROPE.continentCode, "SAN_MARINO", "산마리노"), + BULGARIA(Continent.EUROPE.continentCode, "BULGARIA", "불가리아"), + LATVIA(Continent.EUROPE.continentCode, "LATVIA", "라트비아"), + SLOVENIA(Continent.EUROPE.continentCode, "SLOVENIA", "슬로베니아"), + PORTUGAL(Continent.EUROPE.continentCode, "PORTUGAL", "포르투갈"), + SWISS(Continent.EUROPE.continentCode, "SWISS", "스위스"), + GERMANY(Continent.EUROPE.continentCode, "GERMANY", "독일"), + NETHERLAND(Continent.EUROPE.continentCode, "NETHERLAND", "네덜란드"), + AUSTRIA(Continent.EUROPE.continentCode, "AUSTRIA", "오스트리아"), + ENGLAND(Continent.EUROPE.continentCode, "ENGLAND", "영국"), + SPAIN(Continent.EUROPE.continentCode, "SPAIN", "스페인"), + TURKIYE(Continent.EUROPE.continentCode, "TURKIYE", "터키"), + ITALY(Continent.EUROPE.continentCode, "ITALY", "이탈리아"), + POLAND(Continent.EUROPE.continentCode, "POLAND", "폴란드"), + ICELAND(Continent.EUROPE.continentCode, "ICELAND", "아이슬란드"), + FINLAND(Continent.EUROPE.continentCode, "FINLAND", "핀란드"), + CZECHIA(Continent.EUROPE.continentCode, "CZECHIA", "체코"), + BELGIUM(Continent.EUROPE.continentCode, "BELGIUM", "벨기에"), + HUNGARY(Continent.EUROPE.continentCode, "HUNGARY", "헝가리"), + IRELAND(Continent.EUROPE.continentCode, "IRELAND", "아일랜드"), + RUSSIA(Continent.EUROPE.continentCode, "RUSSIA", "러시아"), + GREECE(Continent.EUROPE.continentCode, "GREECE", "그리스"), + DENMARK(Continent.EUROPE.continentCode, "DENMARK", "덴마크"), + NORWAY(Continent.EUROPE.continentCode, "NORWAY", "노르웨이"), + SWEDEN(Continent.EUROPE.continentCode, "SWEDEN", "스웨덴"), + FRANCE(Continent.EUROPE.continentCode, "FRANCE", "프랑스"), + + AUSTRALIA(Continent.OCEANIA.continentCode, "AUSTRALIA", "호주"), + PALAU(Continent.OCEANIA.continentCode, "PALAU", "팔라우"), + PAPUA_NEW_GUINEA(Continent.OCEANIA.continentCode, "PAPUA_NEW_GUINEA", "파푸아뉴기니"), + TONGA(Continent.OCEANIA.continentCode, "TONGA", "퉁가"), + KIRIBATI(Continent.OCEANIA.continentCode, "KIRIBATI", "키리바시"), + SOLOMON_ISLAND(Continent.OCEANIA.continentCode, "SOLOMON_ISLAND", "솔로몬제도"), + SAMOA(Continent.OCEANIA.continentCode, "SAMOA", "사모아"), + + TUNISIA(Continent.AFRICA.continentCode, "TUNISIA", "튀니지"), + ANGOLA(Continent.AFRICA.continentCode, "ANGOLA", "앙골라"), + ALGERIA(Continent.AFRICA.continentCode, "ALGERIA", "알제리"), + SIERRA_LEONE(Continent.AFRICA.continentCode, "SIERRA_LEONE", "시에라리온"), + SUDAN(Continent.AFRICA.continentCode, "SUDAN", "수단"), + SOMALIA(Continent.AFRICA.continentCode, "SOMALIA", "소말리아"), + SEYCHELLES(Continent.AFRICA.continentCode, "SEYCHELLES", "세이셸"), + SENEGAL(Continent.AFRICA.continentCode, "SENEGAL", "세네갈"), + UGANDA(Continent.AFRICA.continentCode, "UGANDA", "우간다"), + ZAMBIA(Continent.AFRICA.continentCode, "ZAMBIA", "잠비아"), + EQUATORIAL_GUINEA(Continent.AFRICA.continentCode, "EQUATORIAL_GUINEA", "적도기니"), + TOGO(Continent.AFRICA.continentCode, "TOGO", "토고"), + CONGO(Continent.AFRICA.continentCode, "CONGO", "콩고"), + COTE_D_IVOIRE(Continent.AFRICA.continentCode, "COTE_D_IVOIRE", "코트디부아르"), + COMOROS(Continent.AFRICA.continentCode, "COMOROS", "코모로"), + ZIMBABWE(Continent.AFRICA.continentCode, "ZIMBABWE", "짐바브웨"), + DJIBOUTI(Continent.AFRICA.continentCode, "DJIBOUTI", "지부티"), + CENTRAL_AFRICAN_REPUBLIC(Continent.AFRICA.continentCode, "CENTRAL_AFRICAN_REPUBLIC", "중앙아프리카공화국"), + BURUNDI(Continent.AFRICA.continentCode, "BURUNDI", "부룬디"), + BURKINA_FASO(Continent.AFRICA.continentCode, "BURKINA_FASO", "부르키나파소"), + GUINEA_BISSAU(Continent.AFRICA.continentCode, "GUINEA_BISSAU", "기니비사우"), + BOTSWANA(Continent.AFRICA.continentCode, "BOTSWANA", "보츠와나"), + GUINEA(Continent.AFRICA.continentCode, "GUINEA", "기니"), + GAMBIA(Continent.AFRICA.continentCode, "GAMBIA", "감비아"), + GABON(Continent.AFRICA.continentCode, "GABON", "가봉"), + GHANA(Continent.AFRICA.continentCode, "GHANA", "가나"), + MOROCCO(Continent.AFRICA.continentCode, "MOROCCO", "모로코"), + NAMIBIA(Continent.AFRICA.continentCode, "NAMIBIA", "나미비아"), + KENYA(Continent.AFRICA.continentCode, "KENYA", "케냐"), + ETHIOPIA(Continent.AFRICA.continentCode, "ETHIOPIA", "에티오피아"), + TANZANIA(Continent.AFRICA.continentCode, "TANZANIA", "탄자니아"), + NIGERIA(Continent.AFRICA.continentCode, "NIGERIA", "나이지리아"), + BENIN(Continent.AFRICA.continentCode, "BENIN", "베넹"), + NIGER(Continent.AFRICA.continentCode, "NIGER", "니제르"), + MOZAMBIQUE(Continent.AFRICA.continentCode, "MOZAMBIQUE", "모잠비크"), + MAURITANIA(Continent.AFRICA.continentCode, "MAURITANIA", "모리타니"), + MAURITIUS(Continent.AFRICA.continentCode, "MAURITIUS", "모리셔스"), + MALI(Continent.AFRICA.continentCode, "MALI", "말리"), + MALAWI(Continent.AFRICA.continentCode, "MALAWI", "말라위"), + MADAGASCAR(Continent.AFRICA.continentCode, "MADAGASCAR", "마다가스카르"), + LIBYA(Continent.AFRICA.continentCode, "LIBYA", "리비아"), + RWANDA(Continent.AFRICA.continentCode, "RWANDA", "르완다"), + LESOTHO(Continent.AFRICA.continentCode, "LESOTHO", "레소토"), + LIBERIA(Continent.AFRICA.continentCode, "LIBERIA", "라이베리아"), + EGYPT(Continent.AFRICA.continentCode, "EGYPT", "이집트"), + + UNITED_STATES(Continent.NORTH_AMERICA.continentCode, "UNITED_STATES", "미국"), + CANADA(Continent.NORTH_AMERICA.continentCode, "CANADA", "캐나다"), + MEXICO(Continent.NORTH_AMERICA.continentCode, "MEXICO", "멕시코"), + ANTIGUA_AND_BARBUDA(Continent.NORTH_AMERICA.continentCode, "ANTIGUA_AND_BARBUDA", "앤티가바부다"), + ARUBA(Continent.NORTH_AMERICA.continentCode, "ARUBA", "아루바"), + ANGUILLA(Continent.NORTH_AMERICA.continentCode, "ANGUILLA", "앵귈라"), + ANTARCTICA(Continent.NORTH_AMERICA.continentCode, "ANTARCTICA", "남극"), + BAHAMAS(Continent.NORTH_AMERICA.continentCode, "BAHAMAS", "바하마"), + BELIZE(Continent.NORTH_AMERICA.continentCode, "BELIZE", "벨리즈"), + BARBADOS(Continent.NORTH_AMERICA.continentCode, "BARBADOS", "바베이도스"), + BERMUDA(Continent.NORTH_AMERICA.continentCode, "BERMUDA", "버뮤다"), + BOLIVIA(Continent.NORTH_AMERICA.continentCode, "BOLIVIA", "볼리비아"), + BRAZIL(Continent.NORTH_AMERICA.continentCode, "BRAZIL", "브라질"), + IRAQ(Continent.NORTH_AMERICA.continentCode, "IRAQ", "이라크"), + QATAR(Continent.NORTH_AMERICA.continentCode, "QATAR", "카타르"), + + ARGENTINA(Continent.SOUTH_AMERICA.continentCode, "ARGENTINA", "아르헨티나"), + CHILE(Continent.SOUTH_AMERICA.continentCode, "CHILE", "칠레"), + COLOMBIA(Continent.SOUTH_AMERICA.continentCode, "COLOMBIA", "콜롬비아"), + ECUADOR(Continent.SOUTH_AMERICA.continentCode, "ECUADOR", "에콰도르"), + PARAGUAY(Continent.SOUTH_AMERICA.continentCode, "PARAGUAY", "파라과이"), + PERU(Continent.SOUTH_AMERICA.continentCode, "PERU", "페루"), + SURINAME(Continent.SOUTH_AMERICA.continentCode, "SURINAME", "수리남"), + URUGUAY(Continent.SOUTH_AMERICA.continentCode, "URUGUAY", "우루과이"), + VENEZUELA(Continent.SOUTH_AMERICA.continentCode, "VENEZUELA", "베네수엘라") + +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/Gender.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/Gender.kt new file mode 100644 index 0000000..b8f302c --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/Gender.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.user + +enum class Gender { + MALE, FEMALE +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/User.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/User.kt new file mode 100644 index 0000000..e4dc887 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/User.kt @@ -0,0 +1,58 @@ +package com.withaeng.domain.user + +import com.withaeng.domain.BaseEntity +import com.withaeng.domain.converter.UserRoleConverter +import jakarta.persistence.* +import java.time.LocalDate + +@Table(name = "users") +@Entity +class User( + @Column(name = "email", nullable = false) + val email: String, + + @Column(name = "password", nullable = false) + var password: String, + + @Column(name = "birth", nullable = false) + val birth: LocalDate, + + @Enumerated(EnumType.STRING) + @Column(name = "gender", nullable = false) + val gender: Gender, + + @Column(name = "manner_score", nullable = false) + var mannerScore: Double = 36.5, + + @Embedded + val profile: UserProfile, + + @OneToOne(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true) + var travelPreference: UserTravelPreference? = null, + + @Convert(converter = UserRoleConverter::class) + @Column(name = "roles") + var roles: Set, +) : BaseEntity() { + + companion object { + fun create( + email: String, + password: String, + birth: LocalDate, + gender: Gender, + nickname: String, + ): User { + return User( + email = email, + password = password, + birth = birth, + gender = gender, + profile = UserProfile( + nickname = nickname + ), + roles = setOf(UserRole.NON_USER) + ) + } + } +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserConsumeStyle.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserConsumeStyle.kt new file mode 100644 index 0000000..c814368 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserConsumeStyle.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.user + +enum class UserConsumeStyle { + BUDGET_FRIENDLY, SPLURGE, LUXURY +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserDrinkingType.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserDrinkingType.kt new file mode 100644 index 0000000..30cf428 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserDrinkingType.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.user + +enum class UserDrinkingType { + FREQUENT_DRINKER, OCCASIONAL_DRINKER, ABSTAINER, NON_DRINKER +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserFoodRestriction.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserFoodRestriction.kt new file mode 100644 index 0000000..1e17016 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserFoodRestriction.kt @@ -0,0 +1,6 @@ +package com.withaeng.domain.user + +enum class UserFoodRestriction { + SHELLFISH, SEAFOOD, SPICY_FOOD, MEAT, STRONGLY_FLAVORED, + DAIRY, RAW_FOOD, GREASY_FOOD, NOODLES, GLUTEN, CARBONATED_DRINKS +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserMbti.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserMbti.kt new file mode 100644 index 0000000..a2c688b --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserMbti.kt @@ -0,0 +1,6 @@ +package com.withaeng.domain.user + +enum class UserMbti { + ISTJ, ISTP, ISFJ, ISFP, INFJ, INTP, INFP, INTJ, + ESTJ, ESTP, ESFJ, ESFP, ENFJ, ENTP, ENFP, ENTJ +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserPreferTravelTheme.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserPreferTravelTheme.kt new file mode 100644 index 0000000..5509e92 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserPreferTravelTheme.kt @@ -0,0 +1,6 @@ +package com.withaeng.domain.user + +enum class UserPreferTravelTheme { + PICTURE, FOOD, TOURIST_ATTRACTION, NATURE, CAFE, MUSEUM, EXHIBITION_HALL, + ART_MUSEUM, LOCAL_FESTIVAL, HEALING_CARE, SHOPPING, HOTEL_VACATION +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserPreferTravelType.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserPreferTravelType.kt new file mode 100644 index 0000000..b067280 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserPreferTravelType.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.user + +enum class UserPreferTravelType { + DOMESTIC, INTERNATIONAL +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserProfile.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserProfile.kt new file mode 100644 index 0000000..79199d4 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserProfile.kt @@ -0,0 +1,16 @@ +package com.withaeng.domain.user + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +class UserProfile( + @Column(name = "nickname", nullable = false) + var nickname: String, + + @Column(name = "introduction", nullable = true) + var introduction: String? = null, + + @Column(name = "profile_image_url", nullable = true) + var profileImageUrl: String? = null, +) diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserRepository.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserRepository.kt new file mode 100644 index 0000000..9bd2074 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserRepository.kt @@ -0,0 +1,8 @@ +package com.withaeng.domain.user + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserRepository : JpaRepository { + fun findByEmail(email: String): User? + fun deleteByEmail(email: String) +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserRole.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserRole.kt new file mode 100644 index 0000000..980e988 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserRole.kt @@ -0,0 +1,13 @@ +package com.withaeng.domain.user + +enum class UserRole( + val role: String +) { + NON_USER("ROLE_NON_USER"), + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"); + + fun getActualRoleName(): String { + return role.split("_")[1] + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserService.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserService.kt new file mode 100644 index 0000000..313f553 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserService.kt @@ -0,0 +1,139 @@ +package com.withaeng.domain.user + +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.user.dto.* +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import kotlin.reflect.full.declaredMemberProperties + +@Service +class UserService(private val userRepository: UserRepository) { + + @Transactional(readOnly = true) + fun findSimpleById(id: Long): UserSimpleDto { + return userRepository.findByIdOrNull(id).getOrThrow().toSimpleDto() + } + + @Transactional(readOnly = true) + fun findDetailById(id: Long): UserDetailDto { + return userRepository.findByIdOrNull(id).getOrThrow().toDetailDto() + } + + @Transactional(readOnly = true) + fun findByEmailOrNull(email: String): UserSimpleDto? { + return userRepository.findByEmail(email)?.toSimpleDto() + } + + @Transactional(readOnly = true) + fun getProfileCompletionPercentage(userId: Long): Int { + val user = userRepository.findByIdOrNull(userId).getOrThrow() + val profile = user.profile + val travelPreference = user.travelPreference + + val profileFields = UserProfile::class.declaredMemberProperties.filter { it.name != "user" } + val travelPreferenceFields = UserTravelPreference::class.declaredMemberProperties.filter { it.name != "user" } + + val totalFields = profileFields.size + travelPreferenceFields.size + var filledFields = 0 + + profileFields.forEach { field -> + if (field.get(profile) != null && field.get(profile) != "") { + filledFields++ + } + } + + travelPreferenceFields.forEach { field -> + if (travelPreference != null && field.get(travelPreference) != null + && field.get(travelPreference) != emptySet() + ) { + filledFields++ + } + } + + return ((filledFields.toDouble() / totalFields) * 100).toInt() + } + + @Transactional + fun create(createUserCommand: CreateUserCommand): UserSimpleDto { + return userRepository.save( + User.create( + email = createUserCommand.email, + nickname = createUserCommand.nickname, + password = createUserCommand.password, + birth = createUserCommand.birth, + gender = createUserCommand.gender + ) + ).toSimpleDto() + } + + @Transactional + fun replaceTravelPreference(userId: Long, command: UpdateTravelPreferenceCommand): UserSimpleDto { + val user = userRepository.findByIdOrNull(userId).getOrThrow() + user.travelPreference = user.travelPreference ?: UserTravelPreference.create(user) + user.travelPreference?.mbti = command.mbti ?: emptySet() + user.travelPreference?.preferTravelType = command.preferTravelType + user.travelPreference?.preferTravelThemes = command.preferTravelThemes ?: emptySet() + user.travelPreference?.consumeStyle = command.consumeStyle + user.travelPreference?.foodRestrictions = command.foodRestrictions ?: emptySet() + user.travelPreference?.smokingType = command.smokingType + user.travelPreference?.drinkingType = command.drinkingType + return user.toSimpleDto() + } + + @Transactional + fun replacePassword(userId: Long, password: String): UserSimpleDto { + val user = userRepository.findByIdOrNull(userId).getOrThrow() + user.password = password + return user.toSimpleDto() + } + + @Transactional + fun grantUserRole(id: Long) { + val user = userRepository.findByIdOrNull(id) + ?: throw WithaengException.of(WithaengExceptionType.SYSTEM_FAIL) + val newUserRoles = user.roles.filter { it != UserRole.NON_USER } + listOf(UserRole.USER) + user.roles = newUserRoles.toSet() + } + + @Transactional + fun updateNickname(id: Long, nickname: String?): UserSimpleDto { + val user = userRepository.findByIdOrNull(id).getOrThrow() + user.profile.nickname = nickname ?: user.profile.nickname + return user.toSimpleDto() + } + + @Transactional + fun putIntroduction(id: Long, introduction: String?): UserSimpleDto { + val user = userRepository.findByIdOrNull(id).getOrThrow() + user.profile.introduction = introduction + return user.toSimpleDto() + } + + @Transactional + fun putProfileImage(id: Long, imageUrl: String): UserSimpleDto { + val user = userRepository.findByIdOrNull(id).getOrThrow() + user.profile.profileImageUrl = imageUrl + return user.toSimpleDto() + } + + @Transactional + fun deleteProfileImage(id: Long) { + val user = userRepository.findByIdOrNull(id).getOrThrow() + user.profile.profileImageUrl = null + } + + @Transactional + fun deleteByEmail(email: String) { + return userRepository.deleteByEmail(email) + } + + private fun User?.getOrThrow(): User { + this ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "해당하는 유저를 찾을 수 없습니다." + ) + return this + } +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserSmokingType.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserSmokingType.kt new file mode 100644 index 0000000..55d9216 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserSmokingType.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.user + +enum class UserSmokingType { + FREQUENT_SMOKER, OCCASIONAL_SMOKER, QUITTING_SMOKER, NON_SMOKER +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserTravelPreference.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserTravelPreference.kt new file mode 100644 index 0000000..37b0d82 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/UserTravelPreference.kt @@ -0,0 +1,49 @@ +package com.withaeng.domain.user + +import com.withaeng.domain.BaseEntity +import com.withaeng.domain.converter.UserFoodRestrictionConverter +import com.withaeng.domain.converter.UserMbtiConverter +import com.withaeng.domain.converter.UserPreferTravelThemeConverter +import jakarta.persistence.* + +@Table(name = "user_travel_preference") +@Entity +class UserTravelPreference( + @Convert(converter = UserMbtiConverter::class) + @Column(name = "mbti", nullable = true) + var mbti: Set = emptySet(), + + @Enumerated(EnumType.STRING) + @Column(name = "prefer_travel_type", nullable = true) + var preferTravelType: UserPreferTravelType? = null, + + @Convert(converter = UserPreferTravelThemeConverter::class) + @Column(name = "prefer_travel_theme", nullable = false) + var preferTravelThemes: Set = emptySet(), + + @Enumerated(EnumType.STRING) + @Column(name = "consume_style", nullable = true) + var consumeStyle: UserConsumeStyle? = null, + + @Convert(converter = UserFoodRestrictionConverter::class) + @Column(name = "food_restriction", nullable = false) + var foodRestrictions: Set = emptySet(), + + @Enumerated(EnumType.STRING) + @Column(name = "smoking_type", nullable = true) + var smokingType: UserSmokingType? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "drinking_type", nullable = true) + var drinkingType: UserDrinkingType? = null, + + @OneToOne + val user: User, +) : BaseEntity() { + + companion object { + fun create(user: User): UserTravelPreference { + return UserTravelPreference(user = user) + } + } +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/dto/UserCommand.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/dto/UserCommand.kt new file mode 100644 index 0000000..10d6e44 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/dto/UserCommand.kt @@ -0,0 +1,28 @@ +package com.withaeng.domain.user.dto + +import com.withaeng.domain.user.* +import java.time.LocalDate + +data class CreateUserCommand( + val email: String, + val password: String, + val birth: LocalDate, + val gender: Gender, + val nickname: String, +) + +data class UpdateProfileCommand( + val nickname: String? = null, + val introduction: String? = null, + val profileImageUrl: String? = null, +) + +data class UpdateTravelPreferenceCommand( + val mbti: Set? = emptySet(), + val preferTravelType: UserPreferTravelType? = null, + val preferTravelThemes: Set? = emptySet(), + val consumeStyle: UserConsumeStyle? = null, + val foodRestrictions: Set? = emptySet(), + val smokingType: UserSmokingType? = null, + val drinkingType: UserDrinkingType? = null, +) \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/dto/UserDto.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/dto/UserDto.kt new file mode 100644 index 0000000..3735c17 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/user/dto/UserDto.kt @@ -0,0 +1,86 @@ +package com.withaeng.domain.user.dto + +import com.withaeng.domain.user.* +import java.time.LocalDate + +data class UserSimpleDto( + val id: Long, + val email: String, + val password: String, + val birth: LocalDate, + val gender: Gender, + val mannerScore: Double, + val profile: UserProfileDto, + val roles: Set, +) + +fun User.toSimpleDto(): UserSimpleDto = UserSimpleDto( + id = id, + email = email, + password = password, + birth = birth, + gender = gender, + mannerScore = mannerScore, + profile = UserProfileDto( + nickname = profile.nickname, + introduction = profile.introduction, + profileImageUrl = profile.profileImageUrl, + ), + roles = roles, +) + +data class UserDetailDto( + val id: Long, + val createdDate: LocalDate, + val email: String, + val password: String, + val birth: LocalDate, + val gender: Gender, + val mannerScore: Double, + val profile: UserProfileDto, + val travelPreference: UserTravelPreferenceDto? = null, + val roles: Set, +) + +data class UserProfileDto( + val nickname: String, + val introduction: String? = null, + val profileImageUrl: String? = null, +) + +data class UserTravelPreferenceDto( + val mbti: Set? = emptySet(), + val preferTravelType: UserPreferTravelType? = null, + val preferTravelThemes: Set = emptySet(), + val consumeStyle: UserConsumeStyle? = null, + val foodRestrictions: Set = emptySet(), + val smokingType: UserSmokingType? = null, + val drinkingType: UserDrinkingType? = null, +) + +fun User.toDetailDto(): UserDetailDto = UserDetailDto( + id = id, + createdDate = createdAt.toLocalDate(), + email = email, + password = password, + birth = birth, + gender = gender, + mannerScore = mannerScore, + profile = UserProfileDto( + nickname = profile.nickname, + introduction = profile.introduction, + profileImageUrl = profile.profileImageUrl, + ), + travelPreference = travelPreference?.toDto(), + roles = roles +) + +fun UserTravelPreference.toDto(): UserTravelPreferenceDto = UserTravelPreferenceDto( + mbti = mbti, + preferTravelType = preferTravelType, + preferTravelThemes = preferTravelThemes, + consumeStyle = consumeStyle, + foodRestrictions = foodRestrictions, + smokingType = smokingType, + drinkingType = drinkingType, +) diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmail.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmail.kt new file mode 100644 index 0000000..8783fdc --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmail.kt @@ -0,0 +1,25 @@ +package com.withaeng.domain.verificationemail + +import com.withaeng.domain.BaseEntity +import jakarta.persistence.* + +@Table(name = "verification_email") +@Entity +class VerificationEmail( + @Column(name = "email", nullable = false) + val email: String, + + @Column(name = "code", nullable = false) + val code: String, + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + val type: VerificationEmailType, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + val status: VerificationEmailStatus = VerificationEmailStatus.YET, + + @Column(name = "user_id", nullable = false) + val userId: Long, +) : BaseEntity() diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailDto.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailDto.kt new file mode 100644 index 0000000..03c688a --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailDto.kt @@ -0,0 +1,23 @@ +package com.withaeng.domain.verificationemail + +import java.time.LocalDateTime + +data class VerificationEmailDto( + val id: Long, + val createdAt: LocalDateTime, + val email: String, + val code: String, + val type: VerificationEmailType, + val status: VerificationEmailStatus, + val userId: Long, +) + +fun VerificationEmail.toDto(): VerificationEmailDto = VerificationEmailDto( + id = id, + createdAt = createdAt, + email = email, + code = code, + type = type, + status = status, + userId = userId, +) diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailRepository.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailRepository.kt new file mode 100644 index 0000000..b439322 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailRepository.kt @@ -0,0 +1,19 @@ +package com.withaeng.domain.verificationemail + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query + +interface VerificationEmailRepository : JpaRepository { + fun findAllByStatusNot(status: VerificationEmailStatus): List + + fun findByEmail(email: String): VerificationEmail? + + @Modifying + @Query("UPDATE VerificationEmail v SET v.status = :status WHERE v.id in :ids") + fun updateStatusByIds(ids: Set, status: VerificationEmailStatus): Int + + fun deleteAllByUserId(userId: Long) + + fun deleteAllByUserIdAndType(id: Long, status: VerificationEmailType) +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailService.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailService.kt new file mode 100644 index 0000000..27549a7 --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailService.kt @@ -0,0 +1,75 @@ +package com.withaeng.domain.verificationemail + +import com.withaeng.common.exception.WithaengException +import com.withaeng.common.exception.WithaengExceptionType +import com.withaeng.domain.user.UserRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class VerificationEmailService( + private val userRepository: UserRepository, + private val verificationEmailRepository: VerificationEmailRepository, +) { + + @Transactional + fun create(email: String, userId: Long, code: String, type: VerificationEmailType): VerificationEmailDto { + userRepository.findByIdOrNull(userId) ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "$userId 에 해당하는 사용자를 찾을 수 없습니다." + ) + if (email.isBlank() || code.isBlank()) { + throw WithaengException.of( + type = WithaengExceptionType.ARGUMENT_NOT_VALID, + message = "이메일 인증을 진행하는데 올바르지 않은 입력입니다." + ) + } + return verificationEmailRepository.save( + VerificationEmail( + email = email, + userId = userId, + code = code, + type = type + ) + ).toDto() + } + + fun findByEmail(email: String): VerificationEmailDto { + return verificationEmailRepository.findByEmail(email)?.toDto() + ?: throw WithaengException.of( + type = WithaengExceptionType.NOT_EXIST, + message = "이메일에 해당하는 요청이 없습니다." + ) + } + + fun findAllByStatusNot(status: VerificationEmailStatus): List { + return verificationEmailRepository.findAllByStatusNot(status).map { it.toDto() } + } + + @Transactional + fun deleteById(id: Long) { + verificationEmailRepository.deleteById(id) + } + + @Transactional + fun deleteAllById(ids: Set) { + verificationEmailRepository.deleteAllById(ids) + } + + @Transactional + fun deleteAllByUserId(userId: Long) { + verificationEmailRepository.deleteAllByUserId(userId) + } + + @Transactional + fun deleteAllByUserIdAndEmailType(userId: Long, emailType: VerificationEmailType) { + verificationEmailRepository.deleteAllByUserIdAndType(userId, emailType) + } + + @Transactional + fun updateStatusByIds(ids: Set, status: VerificationEmailStatus): Int { + return verificationEmailRepository.updateStatusByIds(ids, status) + } +} diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailStatus.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailStatus.kt new file mode 100644 index 0000000..b33b0cb --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailStatus.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.verificationemail + +enum class VerificationEmailStatus { + YET, DONE, FAILED +} \ No newline at end of file diff --git a/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailType.kt b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailType.kt new file mode 100644 index 0000000..57d3ecc --- /dev/null +++ b/withaeng-domain/src/main/kotlin/com/withaeng/domain/verificationemail/VerificationEmailType.kt @@ -0,0 +1,5 @@ +package com.withaeng.domain.verificationemail + +enum class VerificationEmailType { + VERIFY_EMAIL, CHANGE_PASSWORD +} diff --git a/withaeng-domain/src/main/resources/application-domain.yml b/withaeng-domain/src/main/resources/application-domain.yml new file mode 100644 index 0000000..fbe0693 --- /dev/null +++ b/withaeng-domain/src/main/resources/application-domain.yml @@ -0,0 +1,56 @@ +spring: + datasource: + hikari: + data-source-properties: + rewriteBatchedStatements: true + jpa: + open-in-view: false + properties: + hibernate.default_batch_fetch_size: 100 + +--- +spring.config.activate.on-profile: local + +spring: + datasource: + driverClassName: org.h2.Driver + url: jdbc:h2:mem:withaeng;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + h2: + console: + enabled: true + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + show_sql: true + format_sql: true + use_sql_comments: true + +--- +spring.config.activate.on-profile: dev + +spring: + datasource: + driverClassName: ${DATABASE_DRIVER} + url: ${DATABASE_URL} + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + jpa: + hibernate: + ddl-auto: update + +--- +spring.config.activate.on-profile: prod + +spring: + datasource: + driverClassName: ${DATABASE_DRIVER} + url: ${DATABASE_URL} + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + jpa: + hibernate: + ddl-auto: none \ No newline at end of file diff --git a/withaeng-external/build.gradle.kts b/withaeng-external/build.gradle.kts new file mode 100644 index 0000000..af6758b --- /dev/null +++ b/withaeng-external/build.gradle.kts @@ -0,0 +1,8 @@ +dependencies { + // Email + implementation("software.amazon.awssdk:ses:2.28.3") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + + // Image + implementation("software.amazon.awssdk:s3:2.28.3") +} \ No newline at end of file diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/email/EmailSender.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/email/EmailSender.kt new file mode 100644 index 0000000..2df83b5 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/email/EmailSender.kt @@ -0,0 +1,6 @@ +package com.withaeng.external.email + +interface EmailSender { + + fun send(to: String, subject: String, content: String) +} \ No newline at end of file diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/email/SesConfig.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/email/SesConfig.kt new file mode 100644 index 0000000..4b6e45d --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/email/SesConfig.kt @@ -0,0 +1,26 @@ +package com.withaeng.external.email + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.ses.SesClient + +@Configuration +class SesConfig( + @Value("\${cloud.aws.credentials.access-key}") + private var accessKey: String, + @Value("\${cloud.aws.credentials.secret-key}") + private var secretKey: String, +) { + + @Bean + fun sesClient(): SesClient { + return SesClient.builder() + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .region(Region.AP_NORTHEAST_2) + .build() + } +} \ No newline at end of file diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/email/SesEmailSender.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/email/SesEmailSender.kt new file mode 100644 index 0000000..976682c --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/email/SesEmailSender.kt @@ -0,0 +1,24 @@ +package com.withaeng.external.email + +import org.springframework.stereotype.Component +import software.amazon.awssdk.services.ses.SesClient + +@Component +class SesEmailSender( + private val sesClient: SesClient, +) : EmailSender { + + companion object { + private const val FROM = "reply@withaeng.com" + } + + override fun send(to: String, subject: String, content: String) { + val request = SesFactory.createRequest( + to = to, + from = FROM, + subject = subject, + content = content + ) + sesClient.sendEmail(request) + } +} diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/email/SesFactory.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/email/SesFactory.kt new file mode 100644 index 0000000..47bc908 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/email/SesFactory.kt @@ -0,0 +1,36 @@ +package com.withaeng.external.email + +import software.amazon.awssdk.services.ses.model.* +import java.nio.charset.StandardCharsets + +object SesFactory { + + fun createRequest(to: String, from: String, subject: String, content: String): SendEmailRequest = + SendEmailRequest.builder() + .destination(destination(to)) + .source(from) + .message(message(utf8Content(subject), body(content))) + .build() + + private fun destination(to: String): Destination = + Destination.builder() + .toAddresses(to) + .build() + + private fun message(content: Content, body: Body): Message = + Message.builder() + .subject(content) + .body(body) + .build() + + private fun body(content: String): Body = + Body.builder() + .html(utf8Content(content)) + .build() + + private fun utf8Content(subject: String): Content = + Content.builder() + .charset(StandardCharsets.UTF_8.name()) + .data(subject) + .build() +} \ No newline at end of file diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/EmailTemplate.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/EmailTemplate.kt new file mode 100644 index 0000000..21e6b20 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/EmailTemplate.kt @@ -0,0 +1,51 @@ +package com.withaeng.external.email.template + +enum class EmailTemplate( + private val subject: String, + private val variables: Map>, +) { + VERIFY_EMAIL( + "같이행 서비스 이메일 인증을 부탁드립니다.", + mapOf( + "email" to String::class.java, + "redirectUrl" to String::class.java + ) + ), + CHANGE_PASSWORD( + "같이행 서비스 이메일 인증을 부탁드립니다.", + mapOf( + "email" to String::class.java, + "redirectUrl" to String::class.java + ) + ); + + fun templateName(): String { + return this.name.lowercase().replace("_", "-") + } + + fun subject(): String { + return this.subject + } + + fun validateVariables(variables: Map) { + validateHasRequiredVariables(variables) + validateVariableTypes(variables) + } + + private fun validateHasRequiredVariables(variables: Map) { + val requiredVariables = this.variables.keys + val missingVariables = requiredVariables.filter { !variables.containsKey(it) } + if (missingVariables.isNotEmpty()) { + throw IllegalArgumentException("Missing variables: $missingVariables") + } + } + + private fun validateVariableTypes(variables: Map) { + val invalidVariables = variables.filter { (key, value) -> + this.variables[key]?.isInstance(value) == false + } + if (invalidVariables.isNotEmpty()) { + throw IllegalArgumentException("Invalid variables: $invalidVariables") + } + } +} diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/EmailTemplateRenderer.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/EmailTemplateRenderer.kt new file mode 100644 index 0000000..111f768 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/EmailTemplateRenderer.kt @@ -0,0 +1,18 @@ +package com.withaeng.external.email.template + +import org.springframework.stereotype.Component +import org.thymeleaf.TemplateEngine +import org.thymeleaf.context.Context + +@Component +class EmailTemplateRenderer( + private val templateEngine: TemplateEngine, +) { + fun render(template: EmailTemplate, variables: Map): String { + template.validateVariables(variables) + val context = Context().apply { + setVariables(variables) + } + return templateEngine.process(template.templateName(), context) + } +} \ No newline at end of file diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/TemplatedEmailSender.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/TemplatedEmailSender.kt new file mode 100644 index 0000000..35bed97 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/TemplatedEmailSender.kt @@ -0,0 +1,5 @@ +package com.withaeng.external.email.template + +interface TemplatedEmailSender { + fun send(to: String, template: EmailTemplate, variables: Map) +} \ No newline at end of file diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/TemplatedEmailSenderImpl.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/TemplatedEmailSenderImpl.kt new file mode 100644 index 0000000..5850499 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/email/template/TemplatedEmailSenderImpl.kt @@ -0,0 +1,16 @@ +package com.withaeng.external.email.template + +import com.withaeng.external.email.EmailSender +import org.springframework.stereotype.Component + +@Component +class TemplatedEmailSenderImpl( + private val emailSender: EmailSender, + private val templateRenderer: EmailTemplateRenderer, +) : TemplatedEmailSender { + + override fun send(to: String, template: EmailTemplate, variables: Map) { + val content = templateRenderer.render(template, variables) + emailSender.send(to, template.subject(), content) + } +} diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/image/AwsS3StorageClient.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/image/AwsS3StorageClient.kt new file mode 100644 index 0000000..ac3ffb9 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/image/AwsS3StorageClient.kt @@ -0,0 +1,22 @@ +package com.withaeng.external.image + +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import software.amazon.awssdk.services.s3.S3Client + +@Component +class AwsS3StorageClient( + private val s3Client: S3Client, + @Value("\${cloud.aws.s3.bucket}") + private var bucket: String, +) : S3StorageClient { + + @Async + override fun delete(objectKey: String) { + s3Client.deleteObject { + it.bucket(bucket) + it.key(objectKey) + } + } +} \ No newline at end of file diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/image/PreSignedUrl.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/image/PreSignedUrl.kt new file mode 100644 index 0000000..ca5f3e4 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/image/PreSignedUrl.kt @@ -0,0 +1,13 @@ +package com.withaeng.external.image + +class PreSignedUrl( + private val url: String, +) { + fun uploadUrl(): String { + return url + } + + fun imageUrl(): String { + return url.substring(0, url.indexOf("?")) + } +} diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/image/PreSignedUrlGenerator.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/image/PreSignedUrlGenerator.kt new file mode 100644 index 0000000..dc66b14 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/image/PreSignedUrlGenerator.kt @@ -0,0 +1,42 @@ +package com.withaeng.external.image + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.time.Duration + + +@Component +class PreSignedUrlGenerator( + private val presigner: S3Presigner, + @Value("\${cloud.aws.s3.bucket}") + private var bucket: String, +) { + + fun generate(objectKey: String): PreSignedUrl = + PreSignedUrl( + presigner.presignPutObject( + preSignRequest( + Duration.ofMinutes(1), objectRequest(objectKey) + ) + ).url().toString() + ) + + private fun preSignRequest( + expiration: Duration, + objectRequest: PutObjectRequest?, + ): PutObjectPresignRequest? = PutObjectPresignRequest.builder() + .signatureDuration(expiration) + .putObjectRequest(objectRequest) + .build() + + private fun objectRequest(objectKey: String): PutObjectRequest? { + val objectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build() + return objectRequest + } +} \ No newline at end of file diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/image/S3Config.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/image/S3Config.kt new file mode 100644 index 0000000..77fc898 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/image/S3Config.kt @@ -0,0 +1,35 @@ +package com.withaeng.external.image + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.presigner.S3Presigner + +@Configuration +class S3Config( + @Value("\${cloud.aws.credentials.access-key}") + private var accessKey: String, + @Value("\${cloud.aws.credentials.secret-key}") + private var secretKey: String, +) { + + @Bean + fun s3Client(): S3Client { + return S3Client.builder() + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .region(Region.AP_NORTHEAST_2) + .build() + } + + @Bean + fun s3Presigner(): S3Presigner { + return S3Presigner.builder() + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .region(Region.AP_NORTHEAST_2) + .build() + } +} \ No newline at end of file diff --git a/withaeng-external/src/main/kotlin/com/withaeng/external/image/S3StorageClient.kt b/withaeng-external/src/main/kotlin/com/withaeng/external/image/S3StorageClient.kt new file mode 100644 index 0000000..dbe3a03 --- /dev/null +++ b/withaeng-external/src/main/kotlin/com/withaeng/external/image/S3StorageClient.kt @@ -0,0 +1,6 @@ +package com.withaeng.external.image + +interface S3StorageClient { + + fun delete(objectKey: String) +} \ No newline at end of file diff --git a/withaeng-external/src/main/resources/application-external.yml b/withaeng-external/src/main/resources/application-external.yml new file mode 100644 index 0000000..629deca --- /dev/null +++ b/withaeng-external/src/main/resources/application-external.yml @@ -0,0 +1,22 @@ +cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + +--- +spring.config.activate.on-profile: local + +spring.thymeleaf.cache: false + +cloud.aws.s3.bucket: withaeng-images-dev + +--- +spring.config.activate.on-profile: dev + +cloud.aws.s3.bucket: withaeng-images-dev + +--- +spring.config.activate.on-profile: prod + +cloud.aws.s3.bucket: withaeng-images \ No newline at end of file diff --git a/withaeng-external/src/main/resources/templates/change-password.html b/withaeng-external/src/main/resources/templates/change-password.html new file mode 100644 index 0000000..926fc9f --- /dev/null +++ b/withaeng-external/src/main/resources/templates/change-password.html @@ -0,0 +1,170 @@ + + + + + + + 같이행(Withaeng) - 비밀번호 재설정 + + +
+
+
+ +
+
+
+
+

+ 비밀번호 재설정 +

+

+ 안녕하세요.
+ 같이행 서비스 이용을 위해 이메일 주소 인증을 요청하셨습니다.
+ 아래 버튼을 클릭하여 서비스를 이용할 수 있습니다. +

+
+
+
+

+ 인증은 5분 이내로 완료해주세요. +

+
+ +
+
+
+ + diff --git a/withaeng-external/src/main/resources/templates/verify-email.html b/withaeng-external/src/main/resources/templates/verify-email.html new file mode 100644 index 0000000..2cd38af --- /dev/null +++ b/withaeng-external/src/main/resources/templates/verify-email.html @@ -0,0 +1,170 @@ + + + + + + + 같이행(Withaeng) - 이메일 인증 + + +
+
+
+ +
+
+
+
+

+ 이메일 주소 인증 메일 +

+

+ 안녕하세요.
+ 같이행 서비스 이용을 위해 이메일 주소 인증을 요청하셨습니다.
+ 아래 버튼을 클릭하여 서비스를 이용할 수 있습니다. +

+
+
+
+

+ 인증은 5분 이내로 완료해주세요. +

+
+ +
+
+
+ +