diff --git a/build.gradle.kts b/build.gradle.kts index 575460ed..60751723 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,12 @@ dependencies { // 태그 제거 implementation("org.jsoup:jsoup:1.15.4") + // 이미지 업로드 + implementation("commons-io:commons-io:2.11.0") + + // 썸네일 보여주기 + implementation("net.coobird:thumbnailator:0.4.19") + } noArg { annotation("jakarta.persistence.Entity") diff --git a/docker-compose-local.yml b/docker-compose-local.yml index ad08265a..39e075e9 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -11,7 +11,6 @@ services: - '3306:3306' volumes: - db:/var/lib/mysql - - $PWD/db/init.sql:/docker-entrypoint-initdb.d/init.sql volumes: db: driver: local diff --git a/docker-compose.yml b/docker-compose.yml index ac6b0f24..f129819c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: args: PROFILE: ${PROFILE} ports: - - 80:8080 + - 8080:8080 environment: SPRING_DATASOURCE_URL: "jdbc:mysql://csereal_db_container:3306/${MYSQL_DATABASE}?serverTimezone=Asia/Seoul&useSSL=false&allowPublicKeyRetrieval=true" SPRING_DATASOURCE_USERNAME: ${MYSQL_USER} diff --git a/src/main/kotlin/com/wafflestudio/csereal/common/Exceptions.kt b/src/main/kotlin/com/wafflestudio/csereal/common/Exceptions.kt index 2809d4f6..ead4693b 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/common/Exceptions.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/common/Exceptions.kt @@ -6,4 +6,5 @@ open class CserealException(msg: String, val status: HttpStatus) : RuntimeExcept class Csereal400(msg: String) : CserealException(msg, HttpStatus.BAD_REQUEST) class Csereal404(msg: String) : CserealException(msg, HttpStatus.NOT_FOUND) class Csereal401(msg: String) : CserealException(msg, HttpStatus.UNAUTHORIZED) + class Csereal409(msg: String) : CserealException(msg, HttpStatus.CONFLICT) } diff --git a/src/main/kotlin/com/wafflestudio/csereal/common/aop/Authenticated.kt b/src/main/kotlin/com/wafflestudio/csereal/common/aop/Authenticated.kt new file mode 100644 index 00000000..fc1dc109 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/common/aop/Authenticated.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.csereal.common.aop + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class AuthenticatedStaff + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class AuthenticatedForReservation diff --git a/src/main/kotlin/com/wafflestudio/csereal/common/aop/SecurityAspect.kt b/src/main/kotlin/com/wafflestudio/csereal/common/aop/SecurityAspect.kt new file mode 100644 index 00000000..99dfe6e2 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/common/aop/SecurityAspect.kt @@ -0,0 +1,53 @@ +package com.wafflestudio.csereal.common.aop + +import com.wafflestudio.csereal.common.CserealException +import com.wafflestudio.csereal.core.user.database.Role +import com.wafflestudio.csereal.core.user.database.UserEntity +import com.wafflestudio.csereal.core.user.database.UserRepository +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.annotation.Before +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.core.oidc.user.OidcUser +import org.springframework.stereotype.Component +import org.springframework.web.context.request.RequestAttributes +import org.springframework.web.context.request.RequestContextHolder + +@Aspect +@Component +class SecurityAspect(private val userRepository: UserRepository) { + + @Before("@annotation(AuthenticatedStaff)") + fun checkStaffAuthentication() { + val user = getLoginUser() + + if (user.role != Role.ROLE_STAFF) { + throw CserealException.Csereal401("권한이 없습니다.") + } + } + + @Before("@annotation(AuthenticatedForReservation)") + fun checkReservationAuthentication() { + val user = getLoginUser() + + if (user.role == null) { + throw CserealException.Csereal401("권한이 없습니다.") + } + } + + private fun getLoginUser(): UserEntity { + val authentication = SecurityContextHolder.getContext().authentication + val principal = authentication.principal + + if (principal !is OidcUser) { + throw CserealException.Csereal401("로그인이 필요합니다.") + } + + val username = principal.idToken.getClaim("username") + val user = userRepository.findByUsername(username) ?: throw CserealException.Csereal404("재로그인이 필요합니다.") + + RequestContextHolder.getRequestAttributes()?.setAttribute("loggedInUser", user, RequestAttributes.SCOPE_REQUEST) + + return user + } + +} diff --git a/src/main/kotlin/com/wafflestudio/csereal/common/controller/ContentEntityType.kt b/src/main/kotlin/com/wafflestudio/csereal/common/controller/ContentEntityType.kt new file mode 100644 index 00000000..0631bd6e --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/common/controller/ContentEntityType.kt @@ -0,0 +1,7 @@ +package com.wafflestudio.csereal.common.controller + +import com.wafflestudio.csereal.core.resource.mainImage.database.MainImageEntity + +interface ContentEntityType { + fun bringMainImage(): MainImageEntity? +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/about/api/AboutController.kt b/src/main/kotlin/com/wafflestudio/csereal/core/about/api/AboutController.kt index 4a1c87c5..07e25126 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/about/api/AboutController.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/about/api/AboutController.kt @@ -4,13 +4,8 @@ import com.wafflestudio.csereal.core.about.dto.AboutDto import com.wafflestudio.csereal.core.about.service.AboutService import jakarta.validation.Valid import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile @RequestMapping("/about") @RestController @@ -25,9 +20,10 @@ class AboutController( @PostMapping("/{postType}") fun createAbout( @PathVariable postType: String, - @Valid @RequestBody request: AboutDto + @Valid @RequestPart("request") request: AboutDto, + @RequestPart("image") image: MultipartFile?, ) : ResponseEntity { - return ResponseEntity.ok(aboutService.createAbout(postType, request)) + return ResponseEntity.ok(aboutService.createAbout(postType, request, image)) } // read 목록이 하나 diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/about/database/AboutEntity.kt b/src/main/kotlin/com/wafflestudio/csereal/core/about/database/AboutEntity.kt index 369e06a9..55f23e6b 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/about/database/AboutEntity.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/about/database/AboutEntity.kt @@ -1,7 +1,9 @@ package com.wafflestudio.csereal.core.about.database import com.wafflestudio.csereal.common.config.BaseTimeEntity +import com.wafflestudio.csereal.common.controller.ContentEntityType import com.wafflestudio.csereal.core.about.dto.AboutDto +import com.wafflestudio.csereal.core.resource.mainImage.database.MainImageEntity import jakarta.persistence.* @Entity(name = "about") @@ -14,8 +16,14 @@ class AboutEntity( var year: Int?, @OneToMany(mappedBy = "about", cascade = [CascadeType.ALL], orphanRemoval = true) - val locations: MutableList = mutableListOf() -) : BaseTimeEntity() { + val locations: MutableList = mutableListOf(), + + @OneToOne + var mainImage: MainImageEntity? = null, + +) : BaseTimeEntity(), ContentEntityType { + override fun bringMainImage(): MainImageEntity? = mainImage + companion object { fun of(postType: AboutPostType, aboutDto: AboutDto): AboutEntity { return AboutEntity( diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/about/dto/AboutDto.kt b/src/main/kotlin/com/wafflestudio/csereal/core/about/dto/AboutDto.kt index 63652e47..fc0612e4 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/about/dto/AboutDto.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/about/dto/AboutDto.kt @@ -12,10 +12,11 @@ data class AboutDto( val year: Int?, val createdAt: LocalDateTime?, val modifiedAt: LocalDateTime?, - val locations: List? + val locations: List?, + val imageURL: String? ) { companion object { - fun of(entity: AboutEntity) : AboutDto = entity.run { + fun of(entity: AboutEntity, imageURL: String?) : AboutDto = entity.run { AboutDto( id = this.id, name = this.name, @@ -24,7 +25,8 @@ data class AboutDto( year = this.year, createdAt = this.createdAt, modifiedAt = this.modifiedAt, - locations = this.locations.map { it.name } + locations = this.locations.map { it.name }, + imageURL = imageURL ) } } diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/about/service/AboutService.kt b/src/main/kotlin/com/wafflestudio/csereal/core/about/service/AboutService.kt index 79b6c8a2..5cac574a 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/about/service/AboutService.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/about/service/AboutService.kt @@ -6,11 +6,13 @@ import com.wafflestudio.csereal.core.about.database.AboutPostType import com.wafflestudio.csereal.core.about.database.AboutRepository import com.wafflestudio.csereal.core.about.database.LocationEntity import com.wafflestudio.csereal.core.about.dto.AboutDto +import com.wafflestudio.csereal.core.resource.mainImage.service.ImageService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile interface AboutService { - fun createAbout(postType: String, request: AboutDto): AboutDto + fun createAbout(postType: String, request: AboutDto, image: MultipartFile?): AboutDto fun readAbout(postType: String): AboutDto fun readAllClubs() : List fun readAllFacilities() : List @@ -19,10 +21,11 @@ interface AboutService { @Service class AboutServiceImpl( - private val aboutRepository: AboutRepository + private val aboutRepository: AboutRepository, + private val imageService: ImageService, ) : AboutService { @Transactional - override fun createAbout(postType: String, request: AboutDto): AboutDto { + override fun createAbout(postType: String, request: AboutDto, image: MultipartFile?): AboutDto { val enumPostType = makeStringToEnum(postType) val newAbout = AboutEntity.of(enumPostType, request) @@ -32,23 +35,30 @@ class AboutServiceImpl( } } + if(image != null) { + imageService.uploadImage(newAbout, image) + } aboutRepository.save(newAbout) - return AboutDto.of(newAbout) + val imageURL = imageService.createImageURL(newAbout.mainImage) + + return AboutDto.of(newAbout, imageURL) } @Transactional(readOnly = true) override fun readAbout(postType: String): AboutDto { val enumPostType = makeStringToEnum(postType) val about = aboutRepository.findByPostType(enumPostType) + val imageURL = imageService.createImageURL(about.mainImage) - return AboutDto.of(about) + return AboutDto.of(about, imageURL) } @Transactional(readOnly = true) override fun readAllClubs(): List { val clubs = aboutRepository.findAllByPostTypeOrderByName(AboutPostType.STUDENT_CLUBS).map { - AboutDto.of(it) + val imageURL = imageService.createImageURL(it.mainImage) + AboutDto.of(it, imageURL) } return clubs @@ -57,7 +67,8 @@ class AboutServiceImpl( @Transactional(readOnly = true) override fun readAllFacilities(): List { val facilities = aboutRepository.findAllByPostTypeOrderByName(AboutPostType.FACILITIES).map { - AboutDto.of(it) + val imageURL = imageService.createImageURL(it.mainImage) + AboutDto.of(it, imageURL) } return facilities @@ -66,7 +77,8 @@ class AboutServiceImpl( @Transactional(readOnly = true) override fun readAllDirections(): List { val directions = aboutRepository.findAllByPostTypeOrderByName(AboutPostType.DIRECTIONS).map { - AboutDto.of(it) + val imageURL = imageService.createImageURL(it.mainImage) + AboutDto.of(it, imageURL) } return directions diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/api/ProfessorController.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/api/ProfessorController.kt index 6caff3a0..b1ee1d72 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/api/ProfessorController.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/api/ProfessorController.kt @@ -6,6 +6,7 @@ import com.wafflestudio.csereal.core.member.dto.SimpleProfessorDto import com.wafflestudio.csereal.core.member.service.ProfessorService import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile @RequestMapping("/professor") @RestController @@ -14,8 +15,11 @@ class ProfessorController( ) { @PostMapping - fun createProfessor(@RequestBody createProfessorRequest: ProfessorDto): ResponseEntity { - return ResponseEntity.ok(professorService.createProfessor(createProfessorRequest)) + fun createProfessor( + @RequestPart("request") createProfessorRequest: ProfessorDto, + @RequestPart("image") image: MultipartFile?, + ): ResponseEntity { + return ResponseEntity.ok(professorService.createProfessor(createProfessorRequest, image)) } @GetMapping("/{professorId}") diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/api/StaffController.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/api/StaffController.kt index 391a3e58..26e0ac86 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/api/StaffController.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/api/StaffController.kt @@ -4,14 +4,8 @@ import com.wafflestudio.csereal.core.member.dto.SimpleStaffDto import com.wafflestudio.csereal.core.member.dto.StaffDto import com.wafflestudio.csereal.core.member.service.StaffService import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile @RequestMapping("/staff") @RestController @@ -20,8 +14,11 @@ class StaffController( ) { @PostMapping - fun createStaff(@RequestBody createStaffRequest: StaffDto): ResponseEntity { - return ResponseEntity.ok(staffService.createStaff(createStaffRequest)) + fun createStaff( + @RequestPart("request") createStaffRequest: StaffDto, + @RequestPart("image") image: MultipartFile?, + ): ResponseEntity { + return ResponseEntity.ok(staffService.createStaff(createStaffRequest,image)) } @GetMapping("/{staffId}") diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/database/ProfessorEntity.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/database/ProfessorEntity.kt index 9e1a24e4..15000bec 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/database/ProfessorEntity.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/database/ProfessorEntity.kt @@ -1,8 +1,10 @@ package com.wafflestudio.csereal.core.member.database import com.wafflestudio.csereal.common.config.BaseTimeEntity +import com.wafflestudio.csereal.common.controller.ContentEntityType import com.wafflestudio.csereal.core.member.dto.ProfessorDto import com.wafflestudio.csereal.core.research.database.LabEntity +import com.wafflestudio.csereal.core.resource.mainImage.database.MainImageEntity import jakarta.persistence.* import java.time.LocalDate @@ -39,8 +41,11 @@ class ProfessorEntity( @OneToMany(mappedBy = "professor", cascade = [CascadeType.ALL], orphanRemoval = true) val careers: MutableList = mutableListOf(), - var imageUri: String? = null -) : BaseTimeEntity() { + @OneToOne + var mainImage: MainImageEntity? = null, + +) : BaseTimeEntity(), ContentEntityType { + override fun bringMainImage(): MainImageEntity? = mainImage companion object { fun of(professorDto: ProfessorDto): ProfessorEntity { @@ -54,7 +59,7 @@ class ProfessorEntity( phone = professorDto.phone, fax = professorDto.fax, email = professorDto.email, - website = professorDto.website + website = professorDto.website, ) } } diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/database/StaffEntity.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/database/StaffEntity.kt index e4c37f82..abfafbe0 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/database/StaffEntity.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/database/StaffEntity.kt @@ -1,10 +1,13 @@ package com.wafflestudio.csereal.core.member.database import com.wafflestudio.csereal.common.config.BaseTimeEntity +import com.wafflestudio.csereal.common.controller.ContentEntityType import com.wafflestudio.csereal.core.member.dto.StaffDto +import com.wafflestudio.csereal.core.resource.mainImage.database.MainImageEntity import jakarta.persistence.CascadeType import jakarta.persistence.Entity import jakarta.persistence.OneToMany +import jakarta.persistence.OneToOne @Entity(name = "staff") class StaffEntity( @@ -18,9 +21,11 @@ class StaffEntity( @OneToMany(mappedBy = "staff", cascade = [CascadeType.ALL], orphanRemoval = true) val tasks: MutableList = mutableListOf(), - var imageUri: String? = null + @OneToOne + var mainImage: MainImageEntity? = null, -) : BaseTimeEntity() { + ) : BaseTimeEntity(), ContentEntityType { + override fun bringMainImage(): MainImageEntity? = mainImage companion object { fun of(staffDto: StaffDto): StaffEntity { @@ -29,7 +34,7 @@ class StaffEntity( role = staffDto.role, office = staffDto.office, phone = staffDto.phone, - email = staffDto.email + email = staffDto.email, ) } } diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/ProfessorDto.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/ProfessorDto.kt index d6a2204b..c48f0a5b 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/ProfessorDto.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/ProfessorDto.kt @@ -24,11 +24,11 @@ data class ProfessorDto( val researchAreas: List, val careers: List, @JsonInclude(JsonInclude.Include.NON_NULL) - var imageUri: String? = null + var imageURL: String? = null ) { companion object { - fun of(professorEntity: ProfessorEntity): ProfessorDto { + fun of(professorEntity: ProfessorEntity, imageURL: String?): ProfessorDto { return ProfessorDto( id = professorEntity.id, name = professorEntity.name, @@ -46,8 +46,9 @@ data class ProfessorDto( educations = professorEntity.educations.map { it.name }, researchAreas = professorEntity.researchAreas.map { it.name }, careers = professorEntity.careers.map { it.name }, - imageUri = professorEntity.imageUri + imageURL = imageURL, ) } + } } diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/SimpleProfessorDto.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/SimpleProfessorDto.kt index b3c3682b..bfe05446 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/SimpleProfessorDto.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/SimpleProfessorDto.kt @@ -10,10 +10,10 @@ data class SimpleProfessorDto( val labName: String?, val phone: String?, val email: String?, - val imageUri: String? + val imageURL: String? ) { companion object { - fun of(professorEntity: ProfessorEntity): SimpleProfessorDto { + fun of(professorEntity: ProfessorEntity, imageURL: String?): SimpleProfessorDto { return SimpleProfessorDto( id = professorEntity.id, name = professorEntity.name, @@ -22,8 +22,9 @@ data class SimpleProfessorDto( labName = professorEntity.lab?.name, phone = professorEntity.phone, email = professorEntity.email, - imageUri = professorEntity.imageUri + imageURL = imageURL ) } + } } diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/SimpleStaffDto.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/SimpleStaffDto.kt index 750f9801..c46c5067 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/SimpleStaffDto.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/SimpleStaffDto.kt @@ -9,11 +9,11 @@ data class SimpleStaffDto( val office: String, val phone: String, val email: String, - val imageUri: String? + val imageURL: String? ) { companion object { - fun of(staffEntity: StaffEntity): SimpleStaffDto { + fun of(staffEntity: StaffEntity, imageURL: String?): SimpleStaffDto { return SimpleStaffDto( id = staffEntity.id, name = staffEntity.name, @@ -21,7 +21,7 @@ data class SimpleStaffDto( office = staffEntity.office, phone = staffEntity.phone, email = staffEntity.email, - imageUri = staffEntity.imageUri + imageURL = imageURL ) } } diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/StaffDto.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/StaffDto.kt index 8cd1ee06..69a1d9fd 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/StaffDto.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/dto/StaffDto.kt @@ -13,10 +13,10 @@ data class StaffDto( val email: String, val tasks: List, @JsonInclude(JsonInclude.Include.NON_NULL) - val imageUri: String? = null + val imageURL: String? = null ) { companion object { - fun of(staffEntity: StaffEntity): StaffDto { + fun of(staffEntity: StaffEntity, imageURL: String?): StaffDto { return StaffDto( id = staffEntity.id, name = staffEntity.name, @@ -25,8 +25,9 @@ data class StaffDto( phone = staffEntity.phone, email = staffEntity.email, tasks = staffEntity.tasks.map { it.name }, - imageUri = staffEntity.imageUri + imageURL = imageURL ) } + } } diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/service/ProfessorService.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/service/ProfessorService.kt index 5710d416..c53746b1 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/service/ProfessorService.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/service/ProfessorService.kt @@ -6,14 +6,14 @@ import com.wafflestudio.csereal.core.member.dto.ProfessorDto import com.wafflestudio.csereal.core.member.dto.ProfessorPageDto import com.wafflestudio.csereal.core.member.dto.SimpleProfessorDto import com.wafflestudio.csereal.core.research.database.LabRepository +import com.wafflestudio.csereal.core.resource.mainImage.service.ImageService import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile interface ProfessorService { - - fun createProfessor(createProfessorRequest: ProfessorDto): ProfessorDto - fun getProfessor(professorId: Long): ProfessorDto + fun createProfessor(createProfessorRequest: ProfessorDto, image: MultipartFile?): ProfessorDto fun getProfessor(professorId: Long): ProfessorDto fun getActiveProfessors(): ProfessorPageDto fun getInactiveProfessors(): List fun updateProfessor(professorId: Long, updateProfessorRequest: ProfessorDto): ProfessorDto @@ -24,12 +24,11 @@ interface ProfessorService { @Transactional class ProfessorServiceImpl( private val labRepository: LabRepository, - private val professorRepository: ProfessorRepository + private val professorRepository: ProfessorRepository, + private val imageService: ImageService, ) : ProfessorService { - - override fun createProfessor(createProfessorRequest: ProfessorDto): ProfessorDto { + override fun createProfessor(createProfessorRequest: ProfessorDto, image: MultipartFile?): ProfessorDto { val professor = ProfessorEntity.of(createProfessorRequest) - if (createProfessorRequest.labId != null) { val lab = labRepository.findByIdOrNull(createProfessorRequest.labId) ?: throw CserealException.Csereal404("해당 연구실을 찾을 수 없습니다. LabId: ${createProfessorRequest.labId}") @@ -48,16 +47,25 @@ class ProfessorServiceImpl( CareerEntity.create(career, professor) } + if(image != null) { + imageService.uploadImage(professor, image) + } + professorRepository.save(professor) - return ProfessorDto.of(professor) + val imageURL = imageService.createImageURL(professor.mainImage) + + return ProfessorDto.of(professor, imageURL) } @Transactional(readOnly = true) override fun getProfessor(professorId: Long): ProfessorDto { val professor = professorRepository.findByIdOrNull(professorId) ?: throw CserealException.Csereal404("해당 교수님을 찾을 수 없습니다. professorId: ${professorId}") - return ProfessorDto.of(professor) + + val imageURL = imageService.createImageURL(professor.mainImage) + + return ProfessorDto.of(professor, imageURL) } @Transactional(readOnly = true) @@ -68,14 +76,20 @@ class ProfessorServiceImpl( "교육 및 연구 지도에 총력을 기울이고 있다.\n\n다수의 외국인 학부생, 대학원생이 재학 중에 있으며 매 학기 전공 필수 과목을 비롯한 " + "30% 이상의 과목이 영어로 개설되고 있어 외국인 학생의 학업을 돕는 동시에 한국인 학생이 세계로 진출하는 초석이 되고 있다. 또한 " + "CSE int’l Luncheon을 개최하여 학부 내 외국인 구성원의 화합과 생활의 불편함을 최소화하는 등 학부 차원에서 최선을 다하고 있다." - val professors = professorRepository.findByStatusNot(ProfessorStatus.INACTIVE).map { SimpleProfessorDto.of(it) } + val professors = professorRepository.findByStatusNot(ProfessorStatus.INACTIVE).map { + val imageURL = imageService.createImageURL(it.mainImage) + SimpleProfessorDto.of(it, imageURL) + } .sortedBy { it.name } return ProfessorPageDto(description, professors) } @Transactional(readOnly = true) override fun getInactiveProfessors(): List { - return professorRepository.findByStatus(ProfessorStatus.INACTIVE).map { SimpleProfessorDto.of(it) } + return professorRepository.findByStatus(ProfessorStatus.INACTIVE).map { + val imageURL = imageService.createImageURL(it.mainImage) + SimpleProfessorDto.of(it, imageURL) + } .sortedBy { it.name } } @@ -128,7 +142,9 @@ class ProfessorServiceImpl( CareerEntity.create(career, professor) } - return ProfessorDto.of(professor) + val imageURL = imageService.createImageURL(professor.mainImage) + + return ProfessorDto.of(professor, imageURL) } override fun deleteProfessor(professorId: Long) { diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/member/service/StaffService.kt b/src/main/kotlin/com/wafflestudio/csereal/core/member/service/StaffService.kt index d76137fb..722b29fd 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/member/service/StaffService.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/member/service/StaffService.kt @@ -6,12 +6,14 @@ import com.wafflestudio.csereal.core.member.database.StaffRepository import com.wafflestudio.csereal.core.member.database.TaskEntity import com.wafflestudio.csereal.core.member.dto.SimpleStaffDto import com.wafflestudio.csereal.core.member.dto.StaffDto +import com.wafflestudio.csereal.core.resource.mainImage.service.ImageService import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile interface StaffService { - fun createStaff(createStaffRequest: StaffDto): StaffDto + fun createStaff(createStaffRequest: StaffDto, image: MultipartFile?): StaffDto fun getStaff(staffId: Long): StaffDto fun getAllStaff(): List fun updateStaff(staffId: Long, updateStaffRequest: StaffDto): StaffDto @@ -21,30 +23,43 @@ interface StaffService { @Service @Transactional class StaffServiceImpl( - private val staffRepository: StaffRepository + private val staffRepository: StaffRepository, + private val imageService: ImageService, ) : StaffService { - override fun createStaff(createStaffRequest: StaffDto): StaffDto { + override fun createStaff(createStaffRequest: StaffDto, image: MultipartFile?): StaffDto { val staff = StaffEntity.of(createStaffRequest) for (task in createStaffRequest.tasks) { TaskEntity.create(task, staff) } + if(image != null) { + imageService.uploadImage(staff, image) + } + staffRepository.save(staff) - return StaffDto.of(staff) + val imageURL = imageService.createImageURL(staff.mainImage) + + return StaffDto.of(staff, imageURL) } @Transactional(readOnly = true) override fun getStaff(staffId: Long): StaffDto { val staff = staffRepository.findByIdOrNull(staffId) ?: throw CserealException.Csereal404("해당 행정직원을 찾을 수 없습니다. staffId: ${staffId}") - return StaffDto.of(staff) + + val imageURL = imageService.createImageURL(staff.mainImage) + + return StaffDto.of(staff, imageURL) } @Transactional(readOnly = true) override fun getAllStaff(): List { - return staffRepository.findAll().map { SimpleStaffDto.of(it) }.sortedBy { it.name } + return staffRepository.findAll().map { + val imageURL = imageService.createImageURL(it.mainImage) + SimpleStaffDto.of(it, imageURL) + }.sortedBy { it.name } } override fun updateStaff(staffId: Long, updateStaffRequest: StaffDto): StaffDto { @@ -66,7 +81,9 @@ class StaffServiceImpl( TaskEntity.create(task, staff) } - return StaffDto.of(staff) + val imageURL = imageService.createImageURL(staff.mainImage) + + return StaffDto.of(staff, imageURL) } override fun deleteStaff(staffId: Long) { diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/news/api/NewsController.kt b/src/main/kotlin/com/wafflestudio/csereal/core/news/api/NewsController.kt index 4dea9962..62fd590b 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/news/api/NewsController.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/news/api/NewsController.kt @@ -7,6 +7,7 @@ import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile @RequestMapping("/news") @RestController @@ -32,9 +33,10 @@ class NewsController( @PostMapping fun createNews( - @Valid @RequestBody request: NewsDto + @Valid @RequestPart("request") request: NewsDto, + @RequestPart("image") image: MultipartFile?, ) : ResponseEntity { - return ResponseEntity.ok(newsService.createNews(request)) + return ResponseEntity.ok(newsService.createNews(request,image)) } @PatchMapping("/{newsId}") diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/news/database/NewsEntity.kt b/src/main/kotlin/com/wafflestudio/csereal/core/news/database/NewsEntity.kt index a2944a97..541f0d34 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/news/database/NewsEntity.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/news/database/NewsEntity.kt @@ -1,11 +1,10 @@ package com.wafflestudio.csereal.core.news.database import com.wafflestudio.csereal.common.config.BaseTimeEntity +import com.wafflestudio.csereal.common.controller.ContentEntityType import com.wafflestudio.csereal.core.news.dto.NewsDto -import jakarta.persistence.CascadeType -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.OneToMany +import com.wafflestudio.csereal.core.resource.mainImage.database.MainImageEntity +import jakarta.persistence.* @Entity(name = "news") class NewsEntity( @@ -23,10 +22,25 @@ class NewsEntity( // 새소식 작성란에도 "가장 위에 표시"가 있더라고요, 혹시 쓸지도 모르니까 남겼습니다 var isPinned: Boolean, + @OneToOne + var mainImage: MainImageEntity? = null, + @OneToMany(mappedBy = "news", cascade = [CascadeType.ALL]) var newsTags: MutableSet = mutableSetOf() -): BaseTimeEntity() { +): BaseTimeEntity(), ContentEntityType { + override fun bringMainImage() = mainImage + companion object { + fun of(newsDto: NewsDto): NewsEntity { + return NewsEntity( + title = newsDto.title, + description = newsDto.description, + isPublic = newsDto.isPublic, + isSlide = newsDto.isSlide, + isPinned = newsDto.isPinned, + ) + } + } fun update(updateNewsRequest: NewsDto) { this.title = updateNewsRequest.title this.description = updateNewsRequest.description diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/news/dto/NewsDto.kt b/src/main/kotlin/com/wafflestudio/csereal/core/news/dto/NewsDto.kt index da7f987c..effec738 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/news/dto/NewsDto.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/news/dto/NewsDto.kt @@ -17,9 +17,10 @@ data class NewsDto( val prevTitle: String?, val nextId: Long?, val nextTitle: String?, + val imageURL: String?, ) { companion object { - fun of(entity: NewsEntity, prevNext: Array?) : NewsDto = entity.run { + fun of(entity: NewsEntity, imageURL: String?, prevNext: Array?) : NewsDto = entity.run { NewsDto( id = this.id, title = this.title, @@ -34,7 +35,7 @@ data class NewsDto( prevTitle = prevNext?.get(0)?.title, nextId = prevNext?.get(1)?.id, nextTitle = prevNext?.get(1)?.title, - + imageURL = imageURL, ) } } diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/news/service/NewsService.kt b/src/main/kotlin/com/wafflestudio/csereal/core/news/service/NewsService.kt index eb638dfb..fb07aada 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/news/service/NewsService.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/news/service/NewsService.kt @@ -4,15 +4,16 @@ import com.wafflestudio.csereal.common.CserealException import com.wafflestudio.csereal.core.news.database.* import com.wafflestudio.csereal.core.news.dto.NewsDto import com.wafflestudio.csereal.core.news.dto.NewsSearchResponse +import com.wafflestudio.csereal.core.resource.mainImage.service.ImageService import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.* +import org.springframework.web.multipart.MultipartFile interface NewsService { fun searchNews(tag: List?, keyword: String?, pageNum: Long): NewsSearchResponse fun readNews(newsId: Long, tag: List?, keyword: String?): NewsDto - fun createNews(request: NewsDto): NewsDto + fun createNews(request: NewsDto, image: MultipartFile?): NewsDto fun updateNews(newsId: Long, request: NewsDto): NewsDto fun deleteNews(newsId: Long) fun enrollTag(tagName: String) @@ -22,7 +23,8 @@ interface NewsService { class NewsServiceImpl( private val newsRepository: NewsRepository, private val tagInNewsRepository: TagInNewsRepository, - private val newsTagRepository: NewsTagRepository + private val newsTagRepository: NewsTagRepository, + private val imageService: ImageService, ) : NewsService { @Transactional(readOnly = true) override fun searchNews( @@ -44,30 +46,32 @@ class NewsServiceImpl( if (news.isDeleted) throw CserealException.Csereal404("삭제된 새소식입니다.(newsId: $newsId)") + val imageURL = imageService.createImageURL(news.mainImage) + val prevNext = newsRepository.findPrevNextId(newsId, tag, keyword) ?: throw CserealException.Csereal400("이전글 다음글이 존재하지 않습니다.(newsId=$newsId)") - return NewsDto.of(news, prevNext) + return NewsDto.of(news, imageURL, prevNext) } @Transactional - override fun createNews(request: NewsDto): NewsDto { - val newNews = NewsEntity( - title = request.title, - description = request.description, - isPublic = request.isPublic, - isSlide = request.isSlide, - isPinned = request.isPinned - ) + override fun createNews(request: NewsDto, image: MultipartFile?): NewsDto { + val newNews = NewsEntity.of(request) for (tagName in request.tags) { val tag = tagInNewsRepository.findByName(tagName) ?: throw CserealException.Csereal404("해당하는 태그가 없습니다") NewsTagEntity.createNewsTag(newNews, tag) } + if(image != null) { + imageService.uploadImage(newNews, image) + } + newsRepository.save(newNews) - return NewsDto.of(newNews, null) + val imageURL = imageService.createImageURL(newNews.mainImage) + + return NewsDto.of(newNews, imageURL, null) } @Transactional @@ -93,7 +97,10 @@ class NewsServiceImpl( NewsTagEntity.createNewsTag(news,tag) } - return NewsDto.of(news, null) + val imageURL = imageService.createImageURL(news.mainImage) + + + return NewsDto.of(news, imageURL, null) } @Transactional diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/reservation/api/ReservceController.kt b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/api/ReservceController.kt new file mode 100644 index 00000000..e0dff854 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/api/ReservceController.kt @@ -0,0 +1,79 @@ +package com.wafflestudio.csereal.core.reservation.api + +import com.wafflestudio.csereal.common.aop.AuthenticatedForReservation +import com.wafflestudio.csereal.common.aop.AuthenticatedStaff +import com.wafflestudio.csereal.core.reservation.dto.ReservationDto +import com.wafflestudio.csereal.core.reservation.dto.ReserveRequest +import com.wafflestudio.csereal.core.reservation.service.ReservationService +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.UUID + +@RequestMapping("/reservation") +@RestController +class ReservationController( + private val reservationService: ReservationService +) { + + @GetMapping("/month") + @AuthenticatedForReservation + fun getMonthlyReservations( + @RequestParam roomId: Long, + @RequestParam year: Int, + @RequestParam month: Int + ): ResponseEntity> { + val start = LocalDateTime.of(year, month, 1, 0, 0) + val end = start.plusMonths(1) + return ResponseEntity.ok(reservationService.getRoomReservationsBetween(roomId, start, end)) + } + + @GetMapping("/week") + @AuthenticatedForReservation + fun getWeeklyReservations( + @RequestParam roomId: Long, + @RequestParam year: Int, + @RequestParam month: Int, + @RequestParam day: Int, + ): ResponseEntity> { + val start = LocalDateTime.of(year, month, day, 0, 0) + val end = start.plusDays(7) + return ResponseEntity.ok(reservationService.getRoomReservationsBetween(roomId, start, end)) + } + + @GetMapping("/{reservationId}") + @AuthenticatedStaff + fun getReservation(@PathVariable reservationId: Long): ResponseEntity { + return ResponseEntity.ok(reservationService.getReservation(reservationId)) + } + + @PostMapping + @AuthenticatedForReservation + fun reserveRoom( + @RequestBody reserveRequest: ReserveRequest + ): ResponseEntity> { + return ResponseEntity.ok(reservationService.reserveRoom(reserveRequest)) + } + + @DeleteMapping("/{reservationId}") + @AuthenticatedForReservation + fun cancelSpecific(@PathVariable reservationId: Long): ResponseEntity { + return ResponseEntity.ok(reservationService.cancelSpecific(reservationId)) + } + + @DeleteMapping("/recurring/{recurrenceId}") + @AuthenticatedForReservation + fun cancelRecurring(@PathVariable recurrenceId: UUID): ResponseEntity { + return ResponseEntity.ok(reservationService.cancelRecurring(recurrenceId)) + } + +} diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/ReservationEntity.kt b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/ReservationEntity.kt new file mode 100644 index 00000000..8412f229 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/ReservationEntity.kt @@ -0,0 +1,69 @@ +package com.wafflestudio.csereal.core.reservation.database + +import com.wafflestudio.csereal.common.CserealException +import com.wafflestudio.csereal.common.config.BaseTimeEntity +import com.wafflestudio.csereal.core.reservation.dto.ReserveRequest +import com.wafflestudio.csereal.core.user.database.UserEntity +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate +import java.time.LocalDateTime +import java.util.* + +@Entity(name = "reservation") +class ReservationEntity( + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + val user: UserEntity, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id") + val room: RoomEntity, + + val title: String, + val contactEmail: String, + val contactPhone: String, + val purpose: String, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + + val recurrenceId: UUID? = null + +) : BaseTimeEntity() { + + @PrePersist + @PreUpdate + fun validateDates() { + if (startTime.isAfter(endTime) || startTime.isEqual(endTime)) { + throw CserealException.Csereal400("종료 시각은 시작 시각 이후여야 합니다.") + } + } + + companion object { + fun create( + user: UserEntity, + room: RoomEntity, + reserveRequest: ReserveRequest, + start: LocalDateTime, + end: LocalDateTime, + recurrenceId: UUID + ): ReservationEntity { + return ReservationEntity( + user = user, + room = room, + title = reserveRequest.title, + contactEmail = reserveRequest.contactEmail, + contactPhone = reserveRequest.contactPhone, + purpose = reserveRequest.purpose, + startTime = start, + endTime = end, + recurrenceId = recurrenceId + ) + } + } + +} diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/ReservationRepository.kt new file mode 100644 index 00000000..dd750501 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/ReservationRepository.kt @@ -0,0 +1,24 @@ +package com.wafflestudio.csereal.core.reservation.database + +import jakarta.persistence.LockModeType +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import java.time.LocalDateTime +import java.util.UUID + +interface ReservationRepository : JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM reservation r WHERE r.room.id = :roomId AND ((:start <= r.startTime AND r.startTime < :end) OR (:start < r.endTime AND r.endTime <= :end) OR (r.startTime <= :start AND r.endTime >= :end))") + fun findByRoomIdAndTimeOverlap(roomId: Long, start: LocalDateTime, end: LocalDateTime): List + + fun findByRoomIdAndStartTimeBetweenOrderByStartTimeAsc( + roomId: Long, + start: LocalDateTime, + end: LocalDateTime + ): List + + fun deleteAllByRecurrenceId(recurrenceId: UUID) + +} diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/RoomEntity.kt b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/RoomEntity.kt new file mode 100644 index 00000000..f76d1c48 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/RoomEntity.kt @@ -0,0 +1,21 @@ +package com.wafflestudio.csereal.core.reservation.database + +import com.wafflestudio.csereal.common.config.BaseTimeEntity +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated + +@Entity(name = "room") +class RoomEntity( + val name: String?, + val location: String, + + val capacity: Int, + + @Enumerated(EnumType.STRING) + val type: RoomType +) : BaseTimeEntity() + +enum class RoomType { + SEMINAR, LAB, LECTURE +} diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/RoomRepository.kt b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/RoomRepository.kt new file mode 100644 index 00000000..c7c9b562 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/database/RoomRepository.kt @@ -0,0 +1,6 @@ +package com.wafflestudio.csereal.core.reservation.database + +import org.springframework.data.jpa.repository.JpaRepository + +interface RoomRepository : JpaRepository { +} diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/reservation/dto/ReservationDto.kt b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/dto/ReservationDto.kt new file mode 100644 index 00000000..f3c3ff3e --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/dto/ReservationDto.kt @@ -0,0 +1,37 @@ +package com.wafflestudio.csereal.core.reservation.dto + +import com.wafflestudio.csereal.core.reservation.database.ReservationEntity +import java.time.LocalDateTime +import java.util.UUID + +data class ReservationDto( + val id: Long, + val recurrenceId: UUID?, + val title: String, + val purpose: String, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val roomName: String?, + val roomLocation: String, + val userName: String, + val contactEmail: String, + val contactPhone: String +) { + companion object { + fun of(reservationEntity: ReservationEntity): ReservationDto { + return ReservationDto( + id = reservationEntity.id, + recurrenceId = reservationEntity.recurrenceId, + title = reservationEntity.title, + purpose = reservationEntity.purpose, + startTime = reservationEntity.startTime, + endTime = reservationEntity.endTime, + roomName = reservationEntity.room.name, + roomLocation = reservationEntity.room.location, + userName = reservationEntity.user.username, + contactEmail = reservationEntity.contactEmail, + contactPhone = reservationEntity.contactPhone + ) + } + } +} diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/reservation/dto/ReserveRequest.kt b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/dto/ReserveRequest.kt new file mode 100644 index 00000000..55befd76 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/dto/ReserveRequest.kt @@ -0,0 +1,14 @@ +package com.wafflestudio.csereal.core.reservation.dto + +import java.time.LocalDateTime + +data class ReserveRequest( + val roomId: Long, + val title: String, + val contactEmail: String, + val contactPhone: String, + val purpose: String, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val recurringWeeks: Int? = null +) diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/reservation/service/ReservationService.kt b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/service/ReservationService.kt new file mode 100644 index 00000000..5941bd4b --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/reservation/service/ReservationService.kt @@ -0,0 +1,105 @@ +package com.wafflestudio.csereal.core.reservation.service + +import com.wafflestudio.csereal.common.CserealException +import com.wafflestudio.csereal.core.reservation.database.* +import com.wafflestudio.csereal.core.reservation.dto.ReservationDto +import com.wafflestudio.csereal.core.reservation.dto.ReserveRequest +import com.wafflestudio.csereal.core.user.database.UserEntity +import com.wafflestudio.csereal.core.user.database.UserRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.core.oidc.user.OidcUser +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.request.RequestAttributes +import org.springframework.web.context.request.RequestContextHolder +import java.time.LocalDateTime +import java.util.* + +interface ReservationService { + fun reserveRoom(reserveRequest: ReserveRequest): List + fun getRoomReservationsBetween(roomId: Long, start: LocalDateTime, end: LocalDateTime): List + fun getReservation(reservationId: Long): ReservationDto + fun cancelSpecific(reservationId: Long) + fun cancelRecurring(recurrenceId: UUID) +} + +@Service +@Transactional +class ReservationServiceImpl( + private val reservationRepository: ReservationRepository, + private val userRepository: UserRepository, + private val roomRepository: RoomRepository +) : ReservationService { + + override fun reserveRoom(reserveRequest: ReserveRequest): List { + var user = RequestContextHolder.getRequestAttributes()?.getAttribute( + "loggedInUser", + RequestAttributes.SCOPE_REQUEST + ) as UserEntity? + + if (user == null) { + val oidcUser = SecurityContextHolder.getContext().authentication.principal as OidcUser + val username = oidcUser.idToken.getClaim("username") + + user = userRepository.findByUsername(username) ?: throw CserealException.Csereal404("재로그인이 필요합니다.") + } + + val room = + roomRepository.findByIdOrNull(reserveRequest.roomId) ?: throw CserealException.Csereal404("Room Not Found") + + val reservations = mutableListOf() + + val recurrenceId = UUID.randomUUID() + + val numberOfWeeks = reserveRequest.recurringWeeks ?: 1 + + for (week in 0 until numberOfWeeks) { + val start = reserveRequest.startTime.plusWeeks(week.toLong()) + val end = reserveRequest.endTime.plusWeeks(week.toLong()) + + // 중복 예약 방지 + val overlappingReservations = reservationRepository.findByRoomIdAndTimeOverlap( + reserveRequest.roomId, + start, + end + ) + if (overlappingReservations.isNotEmpty()) { + throw CserealException.Csereal409("${week}주차 해당 시간에 이미 예약이 있습니다.") + } + + val newReservation = ReservationEntity.create(user, room, reserveRequest, start, end, recurrenceId) + reservations.add(newReservation) + } + + reservationRepository.saveAll(reservations) + + return reservations.map { ReservationDto.of(it) } + } + + @Transactional(readOnly = true) + override fun getRoomReservationsBetween( + roomId: Long, + start: LocalDateTime, + end: LocalDateTime + ): List { + return reservationRepository.findByRoomIdAndStartTimeBetweenOrderByStartTimeAsc(roomId, start, end) + .map { ReservationDto.of(it) } + } + + @Transactional(readOnly = true) + override fun getReservation(reservationId: Long): ReservationDto { + val reservationEntity = + reservationRepository.findByIdOrNull(reservationId) ?: throw CserealException.Csereal404("예약을 찾을 수 없습니다.") + return ReservationDto.of(reservationEntity) + } + + override fun cancelSpecific(reservationId: Long) { + reservationRepository.deleteById(reservationId) + } + + override fun cancelRecurring(recurrenceId: UUID) { + reservationRepository.deleteAllByRecurrenceId(recurrenceId) + } + +} diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/api/MainImageController.kt b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/api/MainImageController.kt new file mode 100644 index 00000000..e74a26da --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/api/MainImageController.kt @@ -0,0 +1,13 @@ +package com.wafflestudio.csereal.core.resource.mainImage.api + +import com.wafflestudio.csereal.core.resource.mainImage.service.ImageService +import org.springframework.web.bind.annotation.* + + +@RequestMapping("/image") +@RestController +class MainImageController( + private val imageService: ImageService +) { + +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/database/MainImageEntity.kt b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/database/MainImageEntity.kt new file mode 100644 index 00000000..28273401 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/database/MainImageEntity.kt @@ -0,0 +1,19 @@ +package com.wafflestudio.csereal.core.resource.mainImage.database + +import com.wafflestudio.csereal.common.config.BaseTimeEntity +import jakarta.persistence.* + + +@Entity(name = "mainImage") +class MainImageEntity( + val isDeleted : Boolean? = true, + + @Column(unique = true) + val filename: String, + + val imagesOrder: Int, + val size: Long, + + ) : BaseTimeEntity() { + +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/database/MainImageRepository.kt b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/database/MainImageRepository.kt new file mode 100644 index 00000000..f67b9dbc --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/database/MainImageRepository.kt @@ -0,0 +1,8 @@ +package com.wafflestudio.csereal.core.resource.mainImage.database + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface MainImageRepository : JpaRepository { +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/dto/MainImageDto.kt b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/dto/MainImageDto.kt new file mode 100644 index 00000000..6ca97407 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/dto/MainImageDto.kt @@ -0,0 +1,8 @@ +package com.wafflestudio.csereal.core.resource.mainImage.dto + +data class MainImageDto( + val filename: String, + val imagesOrder: Int, + val size: Long, +) { +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/service/MainImageService.kt b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/service/MainImageService.kt new file mode 100644 index 00000000..ec75e77f --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/csereal/core/resource/mainImage/service/MainImageService.kt @@ -0,0 +1,118 @@ +package com.wafflestudio.csereal.core.resource.mainImage.service + +import com.wafflestudio.csereal.common.CserealException +import com.wafflestudio.csereal.common.controller.ContentEntityType +import com.wafflestudio.csereal.core.about.database.AboutEntity +import com.wafflestudio.csereal.core.member.database.ProfessorEntity +import com.wafflestudio.csereal.core.member.database.StaffEntity +import com.wafflestudio.csereal.core.news.database.NewsEntity +import com.wafflestudio.csereal.core.resource.mainImage.database.MainImageRepository +import com.wafflestudio.csereal.core.resource.mainImage.database.MainImageEntity +import com.wafflestudio.csereal.core.resource.mainImage.dto.MainImageDto +import com.wafflestudio.csereal.core.seminar.database.SeminarEntity +import net.coobird.thumbnailator.Thumbnailator +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import org.apache.commons.io.FilenameUtils +import java.lang.invoke.WrongMethodTypeException +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.io.path.fileSize +import kotlin.io.path.name + + +interface ImageService { + fun uploadImage( + contentEntityType: ContentEntityType, + requestImage: MultipartFile, + ): MainImageDto + fun createImageURL(image: MainImageEntity?) : String? +} + +@Service +class ImageServiceImpl( + private val imageRepository: MainImageRepository, + @Value("\${csereal.upload.path}") + private val path: String, +) : ImageService { + + @Transactional + override fun uploadImage( + contentEntity: ContentEntityType, + requestImage: MultipartFile, + ): MainImageDto { + Files.createDirectories(Paths.get(path)) + + val extension = FilenameUtils.getExtension(requestImage.originalFilename) + + if(!listOf("jpg", "jpeg", "png").contains(extension)) { + throw CserealException.Csereal400("파일의 형식은 jpg, jpeg, png 중 하나여야 합니다.") + } + + val timeMillis = System.currentTimeMillis() + + val filename = "${timeMillis}_${requestImage.originalFilename}" + val totalFilename = path + filename + val saveFile = Paths.get("$totalFilename.$extension") + requestImage.transferTo(saveFile) + + val totalThumbnailFilename = "${path}thumbnail_$filename" + val thumbnailFile = Paths.get("$totalThumbnailFilename.$extension") + Thumbnailator.createThumbnail(saveFile.toFile(), thumbnailFile.toFile(), 100, 100); + + val image = MainImageEntity( + filename = filename, + imagesOrder = 1, + size = requestImage.size, + ) + + val thumbnail = MainImageEntity( + filename = thumbnailFile.name, + imagesOrder = 1, + size = thumbnailFile.fileSize() + ) + + connectImageToEntity(contentEntity, image) + imageRepository.save(image) + imageRepository.save(thumbnail) + + return MainImageDto( + filename = filename, + imagesOrder = 1, + size = requestImage.size + ) + } + + @Transactional + override fun createImageURL(image: MainImageEntity?) : String? { + return if(image != null) { + "http://cse-dev-waffle.bacchus.io/image/${image.filename}" + } else null + } + + private fun connectImageToEntity(contentEntity: ContentEntityType, image: MainImageEntity) { + when (contentEntity) { + is NewsEntity -> { + contentEntity.mainImage = image + } + is SeminarEntity -> { + contentEntity.mainImage = image + } + is AboutEntity -> { + contentEntity.mainImage = image + } + is ProfessorEntity -> { + contentEntity.mainImage = image + } + is StaffEntity -> { + contentEntity.mainImage = image + } + else -> { + throw WrongMethodTypeException("해당하는 엔티티가 없습니다") + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/seminar/api/SeminarController.kt b/src/main/kotlin/com/wafflestudio/csereal/core/seminar/api/SeminarController.kt index 8b2699d7..f46cca7f 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/seminar/api/SeminarController.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/seminar/api/SeminarController.kt @@ -6,6 +6,7 @@ import com.wafflestudio.csereal.core.seminar.service.SeminarService import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile @RequestMapping("/seminar") @RestController @@ -21,10 +22,10 @@ class SeminarController ( } @PostMapping fun createSeminar( - @Valid @RequestBody request: SeminarDto + @Valid @RequestPart("request") request: SeminarDto, + @RequestPart("image") image: MultipartFile? ) : ResponseEntity { - return ResponseEntity.ok(seminarService.createSeminar(request)) - } + return ResponseEntity.ok(seminarService.createSeminar(request,image)) } @GetMapping("/{seminarId}") fun readSeminar( diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/seminar/database/SeminarEntity.kt b/src/main/kotlin/com/wafflestudio/csereal/core/seminar/database/SeminarEntity.kt index bc098643..e7b48fe0 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/seminar/database/SeminarEntity.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/seminar/database/SeminarEntity.kt @@ -1,9 +1,12 @@ package com.wafflestudio.csereal.core.seminar.database import com.wafflestudio.csereal.common.config.BaseTimeEntity +import com.wafflestudio.csereal.common.controller.ContentEntityType +import com.wafflestudio.csereal.core.resource.mainImage.database.MainImageEntity import com.wafflestudio.csereal.core.seminar.dto.SeminarDto import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.OneToOne @Entity(name = "seminar") class SeminarEntity( @@ -36,16 +39,18 @@ class SeminarEntity( var host: String?, - // var profileImage: File, - // var seminarFile: File, var isPublic: Boolean, var isSlide: Boolean, - var additionalNote: String? -): BaseTimeEntity() { + var additionalNote: String?, + + @OneToOne + var mainImage: MainImageEntity? = null, +): BaseTimeEntity(), ContentEntityType { + override fun bringMainImage(): MainImageEntity? = mainImage companion object { fun of(seminarDto: SeminarDto): SeminarEntity { @@ -67,10 +72,9 @@ class SeminarEntity( host = seminarDto.host, additionalNote = seminarDto.additionalNote, isPublic = seminarDto.isPublic, - isSlide = seminarDto.isSlide + isSlide = seminarDto.isSlide, ) } - } fun update(updateSeminarRequest: SeminarDto) { diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/seminar/dto/SeminarDto.kt b/src/main/kotlin/com/wafflestudio/csereal/core/seminar/dto/SeminarDto.kt index b2563385..2eda7a96 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/seminar/dto/SeminarDto.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/seminar/dto/SeminarDto.kt @@ -19,7 +19,6 @@ data class SeminarDto( val endTime: String?, val location: String, val host: String?, - // val profileImage: File, // val seminarFile: File, val additionalNote: String?, val isPublic: Boolean, @@ -27,11 +26,12 @@ data class SeminarDto( val prevId: Long?, val prevTitle: String?, val nextId: Long?, - val nextTitle: String? + val nextTitle: String?, + val imageURL: String?, ) { companion object { - fun of(entity: SeminarEntity, prevNext: Array?): SeminarDto = entity.run { + fun of(entity: SeminarEntity, imageURL: String?, prevNext: Array?): SeminarDto = entity.run { SeminarDto( id = this.id, title = this.title, @@ -55,7 +55,8 @@ data class SeminarDto( prevId = prevNext?.get(0)?.id, prevTitle = prevNext?.get(0)?.title, nextId = prevNext?.get(1)?.id, - nextTitle = prevNext?.get(1)?.title + nextTitle = prevNext?.get(1)?.title, + imageURL = imageURL, ) } diff --git a/src/main/kotlin/com/wafflestudio/csereal/core/seminar/service/SeminarService.kt b/src/main/kotlin/com/wafflestudio/csereal/core/seminar/service/SeminarService.kt index cf6a5cba..274a178e 100644 --- a/src/main/kotlin/com/wafflestudio/csereal/core/seminar/service/SeminarService.kt +++ b/src/main/kotlin/com/wafflestudio/csereal/core/seminar/service/SeminarService.kt @@ -1,6 +1,7 @@ package com.wafflestudio.csereal.core.seminar.service import com.wafflestudio.csereal.common.CserealException +import com.wafflestudio.csereal.core.resource.mainImage.service.ImageService import com.wafflestudio.csereal.core.seminar.database.SeminarEntity import com.wafflestudio.csereal.core.seminar.database.SeminarRepository import com.wafflestudio.csereal.core.seminar.dto.SeminarDto @@ -8,10 +9,11 @@ import com.wafflestudio.csereal.core.seminar.dto.SeminarSearchResponse import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile interface SeminarService { fun searchSeminar(keyword: String?, pageNum: Long): SeminarSearchResponse - fun createSeminar(request: SeminarDto): SeminarDto + fun createSeminar(request: SeminarDto, image: MultipartFile?): SeminarDto fun readSeminar(seminarId: Long, keyword: String?): SeminarDto fun updateSeminar(seminarId: Long, request: SeminarDto): SeminarDto fun deleteSeminar(seminarId: Long) @@ -19,7 +21,8 @@ interface SeminarService { @Service class SeminarServiceImpl( - private val seminarRepository: SeminarRepository + private val seminarRepository: SeminarRepository, + private val imageService: ImageService, ) : SeminarService { @Transactional(readOnly = true) override fun searchSeminar(keyword: String?, pageNum: Long): SeminarSearchResponse { @@ -27,12 +30,19 @@ class SeminarServiceImpl( } @Transactional - override fun createSeminar(request: SeminarDto): SeminarDto { + override fun createSeminar(request: SeminarDto, image: MultipartFile?): SeminarDto { val newSeminar = SeminarEntity.of(request) + if(image != null) { + imageService.uploadImage(newSeminar, image) + } + seminarRepository.save(newSeminar) - return SeminarDto.of(newSeminar, null) + val imageURL = imageService.createImageURL(newSeminar.mainImage) + + + return SeminarDto.of(newSeminar, imageURL, null) } @Transactional(readOnly = true) @@ -42,9 +52,11 @@ class SeminarServiceImpl( if (seminar.isDeleted) throw CserealException.Csereal400("삭제된 세미나입니다. (seminarId: $seminarId)") + val imageURL = imageService.createImageURL(seminar.mainImage) + val prevNext = seminarRepository.findPrevNextId(seminarId, keyword) - return SeminarDto.of(seminar, prevNext) + return SeminarDto.of(seminar, imageURL, prevNext) } @Transactional @@ -55,7 +67,9 @@ class SeminarServiceImpl( seminar.update(request) - return SeminarDto.of(seminar, null) + val imageURL = imageService.createImageURL(seminar.mainImage) + + return SeminarDto.of(seminar, imageURL, null) } @Transactional override fun deleteSeminar(seminarId: Long) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b49fa8e6..9990d0e5 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -23,17 +23,19 @@ server: session: timeout: 7200 # 2시간 + springdoc: swagger-ui: path: index.html api-docs: path: /api-docs/json -logging.level: - default: INFO - org: - springframework: - security: DEBUG +servlet: + multipart: + enabled: true + max-request-size: 100MB + max-file-size: 10MB + --- spring: @@ -48,10 +50,26 @@ spring: show-sql: true open-in-view: false +logging.level: + default: INFO + org: + springframework: + security: DEBUG + +csereal: + upload: + path: /app/image/ --- spring: config.activate.on-profile: prod jpa: hibernate: - ddl-auto: create # TODO: change to validate (or none) when save actual data to server + ddl-auto: update # TODO: change to validate (or none) when save actual data to server open-in-view: false + + + +csereal: + upload: + path: /app/image/ +