diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 865470c34..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @Choi-JJunho @Invidam @songsunkook @daheeParkk diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml new file mode 100644 index 000000000..cc61b832b --- /dev/null +++ b/.github/pr-labeler.yml @@ -0,0 +1,6 @@ +기능: feature/* +버그: fix/* +리팩터링: refactor/* +문서: docs/* +테스트: test/* +인프라: infra/* diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..712c71bc1 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,12 @@ +name: PR Labeler +on: + pull_request: + types: [ opened ] + +jobs: + pr-labeler: + runs-on: ubuntu-latest + steps: + - uses: TimonVS/pr-labeler-action@v4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 98412772b..b4ddde3f7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ .DS_STORE application.yml +*adminsdk.json + +logs diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..82171c733 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM amazoncorretto:17 + +WORKDIR /app + +COPY ./build/libs/KOIN_API_V2.jar /app/app.jar + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/build.gradle b/build.gradle index 7f5da45c9..f70504818 100644 --- a/build.gradle +++ b/build.gradle @@ -21,15 +21,59 @@ repositories { } dependencies { + implementation group: 'org.json', name: 'json', version: '20231013' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + + implementation 'org.jsoup:jsoup:1.15.3' + implementation 'com.amazonaws:aws-java-sdk:1.12.672' + implementation 'com.google.code.gson:gson:2.10.1' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + + // security + implementation 'org.springframework.security:spring-security-crypto:6.2.2' + + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.testcontainers:testcontainers:1.19.3' + testImplementation 'org.testcontainers:junit-jupiter:1.19.3' + testImplementation 'org.testcontainers:mysql' testImplementation 'io.rest-assured:rest-assured:5.3.2' testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // presigned url + implementation platform('software.amazon.awssdk:bom:2.20.56') + implementation 'software.amazon.awssdk:s3' + + // localstack + testImplementation 'org.testcontainers:localstack' + + // flyway + implementation 'org.flywaydb:flyway-mysql' + + // fcm + implementation 'com.google.firebase:firebase-admin:9.2.0' + + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // slack Notification + implementation 'com.github.maricn:logback-slack-appender:1.4.0' } tasks.named('bootBuildImage') { diff --git a/src/main/java/in/koreatech/koin/KoinApplication.java b/src/main/java/in/koreatech/koin/KoinApplication.java index fe91d572e..6908a96b4 100644 --- a/src/main/java/in/koreatech/koin/KoinApplication.java +++ b/src/main/java/in/koreatech/koin/KoinApplication.java @@ -2,8 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication +@ConfigurationPropertiesScan public class KoinApplication { public static void main(String[] args) { diff --git a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java new file mode 100644 index 000000000..03ee22a26 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.admin.land.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.admin.land.dto.AdminLandsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Admin) AdminLand: 복덕방", description = "관리자 권한으로 복덕방 정보를 관리한다") +public interface AdminLandApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 목록 조회") + @GetMapping("/admin/lands") + ResponseEntity getLands( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted + ); +} diff --git a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java new file mode 100644 index 000000000..6fa564ce9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.admin.land.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.admin.land.dto.AdminLandsResponse; +import in.koreatech.koin.admin.land.service.AdminLandService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AdminLandController implements AdminLandApi { + + private final AdminLandService adminLandService; + + @Override + @GetMapping("/admin/lands") + public ResponseEntity getLands( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted + ) { + return ResponseEntity.ok().body(adminLandService.getLands(page, limit, isDeleted)); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java new file mode 100644 index 000000000..0c5e1d1cc --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java @@ -0,0 +1,41 @@ +package in.koreatech.koin.admin.land.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.land.model.Land; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminLandResponse( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "금실타운", requiredMode = REQUIRED) + String name, + + @Schema(description = "종류", example = "원룸") + String roomType, + + @Schema(description = "월세", example = "200만원 (6개월)") + String monthlyFee, + + @Schema(description = "전세", example = "3500") + String charterFee, + + @Schema(description = "삭제(soft delete) 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted +) { + public static AdminLandResponse from(Land land) { + return new AdminLandResponse( + land.getId(), + land.getName(), + land.getRoomType(), + land.getMonthlyFee(), + land.getCharterFee(), + land.isDeleted() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java new file mode 100644 index 000000000..5d2676a99 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java @@ -0,0 +1,46 @@ +package in.koreatech.koin.admin.land.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.land.model.Land; +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminLandsResponse( + + @Schema(description = "조건에 해당하는 총 집의 수", example = "57", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "조건에 해당하는 집 중에 현재 페이지에서 조회된 수", example = "10", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "조건에 해당하는 집들을 조회할 수 있는 최대 페이지", example = "6", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "2", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "집 정보 리스트", requiredMode = REQUIRED) + List lands +) { + public static AdminLandsResponse of(Page pagedResult, Criteria criteria) { + return new AdminLandsResponse( + pagedResult.getTotalElements(), + pagedResult.getContent().size(), + pagedResult.getTotalPages(), + criteria.getPage() + 1, + pagedResult.getContent() + .stream() + .map(AdminLandResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java b/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java new file mode 100644 index 000000000..cce5c009d --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.admin.land.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.land.model.Land; + +public interface AdminLandRepository extends Repository { + + Page findAllByIsDeleted(boolean isDeleted, Pageable pageable); + + Integer countAllByIsDeleted(boolean isDeleted); + + Land save(Land request); + +} diff --git a/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java b/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java new file mode 100644 index 000000000..c106250c9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.admin.land.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.land.dto.AdminLandsResponse; +import in.koreatech.koin.admin.land.repository.AdminLandRepository; +import in.koreatech.koin.domain.land.model.Land; +import in.koreatech.koin.global.model.Criteria; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminLandService { + + private final AdminLandRepository adminLandRepository; + + public AdminLandsResponse getLands(Integer page, Integer limit, Boolean isDeleted) { + + // page > totalPage인 경우 totalPage로 조회하기 위해 + Integer total = adminLandRepository.countAllByIsDeleted(isDeleted); + + Criteria criteria = Criteria.of(page, limit, total); + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), + Sort.by(Sort.Direction.ASC, "id")); + + Page result = adminLandRepository.findAllByIsDeleted(isDeleted, pageRequest); + + return AdminLandsResponse.of(result, criteria); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/activity/controller/ActivityApi.java b/src/main/java/in/koreatech/koin/domain/activity/controller/ActivityApi.java new file mode 100644 index 000000000..bd9176f1d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/activity/controller/ActivityApi.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.activity.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.activity.dto.ActivitiesResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Activity", description = "BCSDLab 활동") +public interface ActivityApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "성공적으로 활동 목록을 조회함"), + @ApiResponse(responseCode = "404", description = "해당하는 활동이 없음", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "BCSD Lab 활동 조회") + @GetMapping("/activities") + ResponseEntity getActivities( + @RequestParam(required = false) String year + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/activity/controller/ActivityController.java b/src/main/java/in/koreatech/koin/domain/activity/controller/ActivityController.java new file mode 100644 index 000000000..e356f8385 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/activity/controller/ActivityController.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.activity.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.activity.dto.ActivitiesResponse; +import in.koreatech.koin.domain.activity.service.ActivityService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class ActivityController implements ActivityApi { + + private final ActivityService activityService; + + @GetMapping("/activities") + public ResponseEntity getActivities( + @RequestParam(required = false) String year + ) { + ActivitiesResponse response = activityService.getActivities(year); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/activity/dto/ActivitiesResponse.java b/src/main/java/in/koreatech/koin/domain/activity/dto/ActivitiesResponse.java new file mode 100644 index 000000000..46835c088 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/activity/dto/ActivitiesResponse.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.activity.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record ActivitiesResponse( + @JsonProperty("Activities") + List activities +) { + public ActivitiesResponse(List activities) { + this.activities = activities; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/activity/dto/ActivityResponse.java b/src/main/java/in/koreatech/koin/domain/activity/dto/ActivityResponse.java new file mode 100644 index 000000000..40c0be1b1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/activity/dto/ActivityResponse.java @@ -0,0 +1,61 @@ +package in.koreatech.koin.domain.activity.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.activity.model.Activity; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ActivityResponse( + @Schema(description = "활동 날짜", example = "2019-07-29", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate date, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted, + + @Schema(description = "최근 업데이트 일시", example = "2019-08-16 23:01:52", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime updatedAt, + + @Schema(description = "초기 생성 일시", example = "2019-08-16 23:01:52", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @Schema(description = "활동 설명", example = "더 편리한 서비스 제공을 위해 시간표 기능을 추가했습니다.", requiredMode = REQUIRED) + String description, + + @Schema(description = "이미지 URL 목록", example = """ + ["https://test2.com.png", "https://test3.com.png"] + """, requiredMode = NOT_REQUIRED) + List imageUrls, + + @Schema(description = "고유 식별자", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "제목", example = "코인 시간표 기능 추가", requiredMode = REQUIRED) + String title +) { + + public static ActivityResponse of(Activity activity, List imageUrls) { + return new ActivityResponse( + activity.getDate(), + activity.isDeleted(), + activity.getUpdatedAt(), + activity.getCreatedAt(), + activity.getDescription(), + imageUrls, + activity.getId(), + activity.getTitle() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/activity/model/Activity.java b/src/main/java/in/koreatech/koin/domain/activity/model/Activity.java new file mode 100644 index 000000000..7d6f1eacd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/activity/model/Activity.java @@ -0,0 +1,58 @@ +package in.koreatech.koin.domain.activity.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDate; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "activities") +@NoArgsConstructor(access = PROTECTED) +public class Activity extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description") + private String description; + + @Column(name = "image_urls") + private String imageUrls; + + @Column(name = "date") + private LocalDate date; + + @Column(name = "is_deleted") + private boolean isDeleted = false; + + @Builder + private Activity( + String title, + String description, + String imageUrls, + LocalDate date, + boolean isDeleted + ) { + this.title = title; + this.description = description; + this.imageUrls = imageUrls; + this.date = date; + this.isDeleted = isDeleted; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/activity/repository/ActivityRepository.java b/src/main/java/in/koreatech/koin/domain/activity/repository/ActivityRepository.java new file mode 100644 index 000000000..df6696ea0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/activity/repository/ActivityRepository.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.activity.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.activity.model.Activity; + +public interface ActivityRepository extends Repository { + + @Query(value = "SELECT * FROM activities WHERE is_deleted = 0 AND YEAR(date) = :year", nativeQuery = true) + List getActivitiesByYear(@Param("year") String year); + + List findAllByIsDeleted(boolean isDeleted); + + Activity save(Activity activity); +} diff --git a/src/main/java/in/koreatech/koin/domain/activity/service/ActivityService.java b/src/main/java/in/koreatech/koin/domain/activity/service/ActivityService.java new file mode 100644 index 000000000..076a3fad4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/activity/service/ActivityService.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.domain.activity.service; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.activity.dto.ActivitiesResponse; +import in.koreatech.koin.domain.activity.dto.ActivityResponse; +import in.koreatech.koin.domain.activity.model.Activity; +import in.koreatech.koin.domain.activity.repository.ActivityRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActivityService { + + private final ActivityRepository activityRepository; + + public ActivitiesResponse getActivities(String year) { + List activities; + + if (year == null) { + activities = activityRepository.findAllByIsDeleted(false); + } else { + activities = activityRepository.getActivitiesByYear(year); + } + + List activityResponseList = activities.stream() + .map(activity -> { + List imageUrlsList = parseImageUrls(activity.getImageUrls()); + return ActivityResponse.of(activity, imageUrlsList); + }) + .toList(); + + return new ActivitiesResponse(activityResponseList); + } + + private List parseImageUrls(String imageUrls) { + if (imageUrls == null || imageUrls.trim().isEmpty()) { + return Collections.emptyList(); + } + + return Arrays.stream(imageUrls.split(",")) + .map(String::strip) + .map(url -> url.replace("\n", "").replace("\r", "")) // 개행 문자 제거 + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java new file mode 100644 index 000000000..0811fe167 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java @@ -0,0 +1,84 @@ +package in.koreatech.koin.domain.bus.controller; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.bus.dto.BusCourseResponse; +import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; +import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; +import in.koreatech.koin.domain.bus.model.BusTimetable; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import in.koreatech.koin.domain.bus.model.enums.BusType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Bus: 버스", description = "버스 정보를 조회한다.") +@RequestMapping("/bus") +public interface BusApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "이번 / 다음 버스 남은 시간 조회") + @GetMapping + ResponseEntity getBusRemainTime( + @Parameter(description = "버스 종류(city, express, shuttle, commuting)") @RequestParam(value = "bus_type") BusType busType, + @Parameter(description = "koreatech, station, terminal") @RequestParam BusStation depart, + @Parameter(description = "koreatech, station, terminal") @RequestParam BusStation arrival + ); + + @Operation(summary = "버스 시간표 조회") + @GetMapping("/timetable") + ResponseEntity> getBusTimetable( + @RequestParam(value = "bus_type") BusType busType, + @RequestParam(value = "direction") String direction, + @RequestParam(value = "region") String region + ); + + @Operation(summary = "버스 시간표 조회") + @GetMapping("/timetable/v2") + ResponseEntity getBusTimetableV2( + @RequestParam(value = "bus_type") BusType busType, + @RequestParam(value = "direction") String direction, + @RequestParam(value = "region") String region + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "날짜 & 시간 기준 버스 검색") + @GetMapping("/search") + ResponseEntity> getSearchTimetable( + @Parameter(description = "yyyy-MM-dd") @RequestParam LocalDate date, + @Parameter(description = "HH:mm") @RequestParam String time, + @Parameter(description = "koreatech, station, terminal") @RequestParam BusStation depart, + @Parameter(description = "koreatech, station, terminal") @RequestParam BusStation arrival + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "버스 노선 조회") + @GetMapping("/courses") + ResponseEntity> getBusCourses(); +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java new file mode 100644 index 000000000..43aeac7c7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java @@ -0,0 +1,75 @@ +package in.koreatech.koin.domain.bus.controller; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.bus.dto.BusCourseResponse; +import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; +import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; +import in.koreatech.koin.domain.bus.model.BusTimetable; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import in.koreatech.koin.domain.bus.model.enums.BusType; +import in.koreatech.koin.domain.bus.service.BusService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/bus") +public class BusController implements BusApi { + + private final BusService busService; + + @GetMapping + public ResponseEntity getBusRemainTime( + @RequestParam(value = "bus_type") BusType busType, + @RequestParam BusStation depart, + @RequestParam BusStation arrival + ) { + BusRemainTimeResponse busRemainTime = busService.getBusRemainTime(busType, depart, arrival); + return ResponseEntity.ok().body(busRemainTime); + } + + @GetMapping("/timetable") + public ResponseEntity> getBusTimetable( + @RequestParam(value = "bus_type") BusType busType, + @RequestParam(value = "direction") String direction, + @RequestParam(value = "region") String region + ) { + return ResponseEntity.ok().body(busService.getBusTimetable(busType, direction, region)); + } + + @GetMapping("/timetable/v2") + public ResponseEntity getBusTimetableV2( + @RequestParam(value = "bus_type") BusType busType, + @RequestParam(value = "direction") String direction, + @RequestParam(value = "region") String region + ) { + return ResponseEntity.ok().body(busService.getBusTimetableWithUpdatedAt(busType, direction, region)); + } + + @GetMapping("/courses") + public ResponseEntity> getBusCourses() { + return ResponseEntity.ok().body(busService.getBusCourses()); + } + + @GetMapping("/search") + public ResponseEntity> getSearchTimetable( + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @RequestParam String time, + @RequestParam BusStation depart, + @RequestParam BusStation arrival + ) { + List singleBusTimeResponses = busService.searchTimetable(date, LocalTime.parse(time), + depart, arrival); + return ResponseEntity.ok().body(singleBusTimeResponses); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusStationEnumConverter.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusStationEnumConverter.java new file mode 100644 index 000000000..7cab37ea2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusStationEnumConverter.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.bus.controller; + +import java.util.Arrays; + +import org.springframework.core.convert.converter.Converter; + +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import in.koreatech.koin.global.domain.upload.exception.ImageUploadDomainNotFoundException; + +public class BusStationEnumConverter implements Converter { + + @Override + public BusStation convert(String source) { + return Arrays.stream(BusStation.values()) + .filter(it -> it.name().equalsIgnoreCase(source)) + .findAny() + .orElseThrow(() -> ImageUploadDomainNotFoundException.withDetail("source: " + source)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusTypeEnumConverter.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusTypeEnumConverter.java new file mode 100644 index 000000000..ec747e504 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusTypeEnumConverter.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.bus.controller; + +import java.util.Arrays; + +import org.springframework.core.convert.converter.Converter; + +import in.koreatech.koin.domain.bus.model.enums.BusType; +import in.koreatech.koin.global.domain.upload.exception.ImageUploadDomainNotFoundException; + +public class BusTypeEnumConverter implements Converter { + + @Override + public BusType convert(String source) { + return Arrays.stream(BusType.values()) + .filter(it -> it.name().equalsIgnoreCase(source)) + .findAny() + .orElseThrow(() -> ImageUploadDomainNotFoundException.withDetail("source: " + source)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/BusCourseResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/BusCourseResponse.java new file mode 100644 index 000000000..e2a153d43 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/BusCourseResponse.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.bus.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.mongo.BusCourse; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record BusCourseResponse( + @Schema(description = "버스 타입", example = "shuttle", requiredMode = NOT_REQUIRED) + String busType, + @Schema(description = "방향", example = "to", requiredMode = NOT_REQUIRED) + String direction, + @Schema(description = "기준", example = "청주", requiredMode = NOT_REQUIRED) + String region + +) { + + public static BusCourseResponse from(BusCourse busCourse) { + return new BusCourseResponse( + busCourse.getBusType(), + busCourse.getDirection(), + busCourse.getRegion() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/BusRemainTimeResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/BusRemainTimeResponse.java new file mode 100644 index 000000000..22afa8b5c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/BusRemainTimeResponse.java @@ -0,0 +1,56 @@ +package in.koreatech.koin.domain.bus.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import java.time.Clock; +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.BusRemainTime; +import in.koreatech.koin.domain.bus.model.city.CityBusRemainTime; +import in.koreatech.koin.domain.bus.model.enums.BusType; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record BusRemainTimeResponse( + @Schema(description = "버스 타입", example = "shuttle", requiredMode = NOT_REQUIRED) + String busType, + InnerBusResponse nowBus, + InnerBusResponse nextBus +) { + + public static BusRemainTimeResponse of(BusType busType, List remainTimes, Clock clock) { + return new BusRemainTimeResponse( + busType.getName(), + InnerBusResponse.of(remainTimes, 0, clock), + InnerBusResponse.of(remainTimes, 1, clock) + ); + } + + @JsonNaming(SnakeCaseStrategy.class) + public record InnerBusResponse( + @Schema(description = "버스 번호", example = "400", requiredMode = NOT_REQUIRED) + Long busNumber, + + @Schema(description = "남은 시간 / 초", example = "10417", requiredMode = NOT_REQUIRED) + Long remainTime + ) { + + public static InnerBusResponse of(List remainTimes, int index, Clock clock) { + if (index < remainTimes.size()) { + Long busNumber = null; + Long remainTime = remainTimes.get(index).getRemainSeconds(clock); + + if (remainTime != null && remainTimes.get(index) instanceof CityBusRemainTime cityBusRemainTime) { + busNumber = cityBusRemainTime.getBusNumber(); + } + + return new InnerBusResponse(busNumber, remainTime); + } + + return null; + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/BusTimetableResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/BusTimetableResponse.java new file mode 100644 index 000000000..6ffd599d4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/BusTimetableResponse.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.domain.bus.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.BusTimetable; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record BusTimetableResponse( + @Schema(description = "버스 시간표", example = """ + { + "route_name": "주말(14시 35분)", + "arrival_info": { + "nodeName": "터미널(신세계 앞 횡단보도)", + "arrivalTime": "14:35" + } + } + """, requiredMode = NOT_REQUIRED) + List busTimetable, + + @Schema(description = "업데이트 시각", example = "2024-04-20 18:00:00", requiredMode = NOT_REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime updatedAt +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/ExpressBusRemainTime.java b/src/main/java/in/koreatech/koin/domain/bus/dto/ExpressBusRemainTime.java new file mode 100644 index 000000000..edd7009a1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/ExpressBusRemainTime.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.bus.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalTime; + +import in.koreatech.koin.domain.bus.model.BusRemainTime; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class ExpressBusRemainTime extends BusRemainTime { + + @Schema(description = "버스 타입", example = "express", requiredMode = REQUIRED) + private final String busType; + + public ExpressBusRemainTime(LocalTime busArrivalTime, String busType) { + super(busArrivalTime, null); + this.busType = busType; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/SingleBusTimeResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/SingleBusTimeResponse.java new file mode 100644 index 000000000..65a59f948 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/SingleBusTimeResponse.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.dto; + +import java.time.LocalTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record SingleBusTimeResponse( + @Schema(description = "버스 타입", example = "shuttle") + String busName, + + @Schema(description = "도착 시간", example = "12:00") + @JsonFormat(pattern = "HH:mm") + LocalTime busTime +) { +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/ApiTypeNotFoundException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/ApiTypeNotFoundException.java new file mode 100644 index 000000000..39ccabdf7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/ApiTypeNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class ApiTypeNotFoundException extends DataNotFoundException { + + public static final String DEFAULT_MESSAGE = "존재하지 않는 API 타입입니다."; + + public ApiTypeNotFoundException(String message) { + super(message); + } + + public ApiTypeNotFoundException(String message, String detail) { + super(message, detail); + } + + public static ApiTypeNotFoundException withDetail(String detail) { + return new ApiTypeNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusArrivalNodeNotFoundException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusArrivalNodeNotFoundException.java new file mode 100644 index 000000000..85216de90 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusArrivalNodeNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class BusArrivalNodeNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "버스 경로 상에 해당 노드가 존재하지 않습니다."; + + public BusArrivalNodeNotFoundException(String message) { + super(message); + } + + public BusArrivalNodeNotFoundException(String message, String detail) { + super(message, detail); + } + + public static BusArrivalNodeNotFoundException withDetail(String detail) { + return new BusArrivalNodeNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusCacheNotFoundException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusCacheNotFoundException.java new file mode 100644 index 000000000..ec24098bc --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusCacheNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class BusCacheNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "버스 캐시가 존재하지 않습니다."; + + public BusCacheNotFoundException(String message) { + super(message); + } + + public BusCacheNotFoundException(String message, String detail) { + super(message, detail); + } + + public static BusCacheNotFoundException withDetail(String detail) { + return new BusCacheNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusDirectionNotFoundException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusDirectionNotFoundException.java new file mode 100644 index 000000000..540d620af --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusDirectionNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class BusDirectionNotFoundException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "정상적인 버스 방향이 아닙니다."; + + public BusDirectionNotFoundException(String message) { + super(message); + } + + public BusDirectionNotFoundException(String message, String detail) { + super(message, detail); + } + + public static BusDirectionNotFoundException withDetail(String detail) { + return new BusDirectionNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalArrivalTime.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalArrivalTime.java new file mode 100644 index 000000000..67918da00 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalArrivalTime.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class BusIllegalArrivalTime extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "버스 도착 시각이 잘못되었습니다."; + + public BusIllegalArrivalTime(String message) { + super(message); + } + + public BusIllegalArrivalTime(String message, String detail) { + super(message, detail); + } + + public static BusIllegalArrivalTime withDetail(String detail) { + return new BusIllegalArrivalTime(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalStationException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalStationException.java new file mode 100644 index 000000000..425517ae9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalStationException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class BusIllegalStationException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "버스 정류장이 잘못되었습니다."; + + public BusIllegalStationException(String message) { + super(message); + } + + public BusIllegalStationException(String message, String detail) { + super(message, detail); + } + + public static BusIllegalStationException withDetail(String detail) { + return new BusIllegalStationException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusNotFoundException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusNotFoundException.java new file mode 100644 index 000000000..c3d890991 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class BusNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "버스가 존재하지 않습니다."; + + public BusNotFoundException(String message) { + super(message); + } + + public BusNotFoundException(String message, String detail) { + super(message, detail); + } + + public static BusNotFoundException withDetail(String detail) { + return new BusNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusOpenApiException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusOpenApiException.java new file mode 100644 index 000000000..3f3fb6577 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusOpenApiException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.ExternalServiceException; + +public class BusOpenApiException extends ExternalServiceException { + + private static final String DEFAULT_MESSAGE = "버스 Open API 응답이 정상적이지 않습니다."; + + public BusOpenApiException(String message) { + super(message); + } + + public BusOpenApiException(String message, String detail) { + super(message, detail); + } + + public static BusOpenApiException withDetail(String detail) { + return new BusOpenApiException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusStationNotFoundException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusStationNotFoundException.java new file mode 100644 index 000000000..2e2c6b9bc --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusStationNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class BusStationNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "버스 정류장이 존재하지 않습니다."; + + public BusStationNotFoundException(String message) { + super(message); + } + + public BusStationNotFoundException(String message, String detail) { + super(message, detail); + } + + public static BusStationNotFoundException withDetail(String detail) { + return new BusStationNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusTypeNotFoundException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusTypeNotFoundException.java new file mode 100644 index 000000000..f4b8b1127 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusTypeNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class BusTypeNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "버스 타입이 존재하지 않습니다."; + + public BusTypeNotFoundException(String message) { + super(message); + } + + public BusTypeNotFoundException(String message, String detail) { + super(message, detail); + } + + public static BusTypeNotFoundException withDetail(String detail) { + return new BusTypeNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusTypeNotSupportException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusTypeNotSupportException.java new file mode 100644 index 000000000..44ef86156 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusTypeNotSupportException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class BusTypeNotSupportException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "해당 버스타입에는 지원하지 않는 기능입니다."; + + public BusTypeNotSupportException(String message) { + super(message); + } + + public BusTypeNotSupportException(String message, String detail) { + super(message, detail); + } + + public static BusTypeNotSupportException withDetail(String detail) { + return new BusTypeNotSupportException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/BusRemainTime.java b/src/main/java/in/koreatech/koin/domain/bus/model/BusRemainTime.java new file mode 100644 index 000000000..daa819c0d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/BusRemainTime.java @@ -0,0 +1,87 @@ +package in.koreatech.koin.domain.bus.model; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.Objects; + +import in.koreatech.koin.domain.bus.exception.BusIllegalArrivalTime; +import lombok.Getter; +import lombok.experimental.SuperBuilder; + +@Getter +@SuperBuilder +public class BusRemainTime implements Comparable { + + private final LocalTime busArrivalTime; + private String busArrivalTimeRaw; + + public boolean isBefore(Clock clock) { + if (busArrivalTime == null) { + return false; + } + return LocalTime.now(clock).isBefore(busArrivalTime); + } + + public Long getRemainSeconds(Clock clock) { + if (isBefore(clock)) { + return Duration.between(LocalTime.now(clock), busArrivalTime).toSeconds(); + } + return null; + } + + public static BusRemainTime from(String arrivalTime) { + try { + return builder() + .busArrivalTime(toLocalTime(arrivalTime)) + .build(); + } catch (BusIllegalArrivalTime e) { + return builder() + .busArrivalTimeRaw(arrivalTime) + .build(); + } + } + + public static BusRemainTime of(Long remainTime, LocalTime updatedAt) { + return builder() + .busArrivalTime(updatedAt.plusSeconds(remainTime)) + .build(); + } + + private static LocalTime toLocalTime(String arrivalTime) { + try { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); + return LocalTime.parse(arrivalTime, formatter); + } catch (Exception e) { + throw BusIllegalArrivalTime.withDetail("arrivalTime: " + arrivalTime); + } + } + + public BusRemainTime(LocalTime busArrivalTime, String busArrivalTimeRaw) { + this.busArrivalTime = busArrivalTime; + this.busArrivalTimeRaw = busArrivalTimeRaw; + } + + @Override + public int hashCode() { + return Objects.hash(busArrivalTime); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BusRemainTime that = (BusRemainTime)o; + return Objects.equals(busArrivalTime, that.busArrivalTime); + } + + @Override + public int compareTo(BusRemainTime o) { + return busArrivalTime.compareTo(o.busArrivalTime); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/BusTimetable.java b/src/main/java/in/koreatech/koin/domain/bus/model/BusTimetable.java new file mode 100644 index 000000000..22b06bd7d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/BusTimetable.java @@ -0,0 +1,5 @@ +package in.koreatech.koin.domain.bus.model; + +public abstract class BusTimetable { + +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/SchoolBusTimetable.java b/src/main/java/in/koreatech/koin/domain/bus/model/SchoolBusTimetable.java new file mode 100644 index 000000000..9ecdf6b84 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/SchoolBusTimetable.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.domain.bus.model; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.Getter; + +@Getter +@JsonNaming(value = SnakeCaseStrategy.class) +public class SchoolBusTimetable extends BusTimetable { + private final String routeName; + private final List arrivalInfo; + + public SchoolBusTimetable(String routeName, List arrivalInfo) { + this.routeName = routeName; + this.arrivalInfo = arrivalInfo; + } + + @Getter + public static class ArrivalNode { + private final String nodeName; + private final String arrivalTime; + + public ArrivalNode(String nodeName, String arrivalTime) { + this.nodeName = nodeName; + this.arrivalTime = arrivalTime; + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusArrival.java b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusArrival.java new file mode 100644 index 000000000..c01749508 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusArrival.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.domain.bus.model.city; + +import lombok.Builder; + +@Builder +public record CityBusArrival( + Long arrprevstationcnt, // 남은 정거장 개수, 5 + Long arrtime, // 도착까지 남은 시간 [초], 222 + String nodeid, // 정류소 id, "CAB285000405" + String nodenm, // 정류소명, "코리아텍" + String routeid, // 노선 id, "CAB285000147" + Long routeno, // 버스 번호, 402 + String routetp, // 노선 유형, "일반 버스" + String vehicletp // 차량 유형 (저상버스), "일반차량" +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusCache.java b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusCache.java new file mode 100644 index 000000000..c51697cf4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusCache.java @@ -0,0 +1,47 @@ +package in.koreatech.koin.domain.bus.model.city; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; + +@Getter +@RedisHash("CityBus") +public class CityBusCache { + + private static final long CACHE_EXPIRE_MINUTE = 1L; + private static final long CACHE_EXPIRE_SECONDS = 60L; + + @Id + private String id; + + private final List busInfos = new ArrayList<>(); + + @TimeToLive(unit = TimeUnit.MINUTES) + private final Long expiration; + + @Builder + private CityBusCache(String id, List busInfos, Long expiration) { + this.id = id; + this.busInfos.addAll(busInfos); + this.expiration = expiration; + } + + public static CityBusCache of(String nodeId, List busInfos) { + return CityBusCache.builder() + .id(nodeId) + .busInfos(busInfos) + .expiration(CACHE_EXPIRE_MINUTE) + .build(); + } + + public static long getCacheExpireSeconds() { + return CACHE_EXPIRE_SECONDS; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusCacheInfo.java b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusCacheInfo.java new file mode 100644 index 000000000..032bc9ab0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusCacheInfo.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.bus.model.city; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +import in.koreatech.koin.domain.bus.model.BusRemainTime; + +public record CityBusCacheInfo( + Long busNumber, + LocalTime remainTime +) { + + /** + *
+     * 남은 시간 = (캐시 저장 시각 + 저장된 남은시간) - 현재시각
+     * {@link BusRemainTime#getRemainSeconds}에서는 남은 시간을 (저장된 남은시간 - 현재시각)으로 계산중
+     * 학교 버스는 도착 시간을 저장하고, 시내버스는 남은 시간만을 저장하므로
+     * Redis에 저장할때 (캐시 저장 시각 + 남은시간)으로 저장하여 통일시켜줌
+ * */ + public static CityBusCacheInfo of(CityBusArrival busArrivalInfo, LocalDateTime updatedAt) { + return new CityBusCacheInfo( + busArrivalInfo.routeno(), + updatedAt.plusSeconds(busArrivalInfo.arrtime()).toLocalTime() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRemainTime.java b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRemainTime.java new file mode 100644 index 000000000..a6482f859 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRemainTime.java @@ -0,0 +1,50 @@ +package in.koreatech.koin.domain.bus.model.city; + +import java.time.LocalTime; +import java.util.Objects; + +import in.koreatech.koin.domain.bus.model.BusRemainTime; +import lombok.Getter; +import lombok.experimental.SuperBuilder; + +@Getter +@SuperBuilder +public class CityBusRemainTime extends BusRemainTime { + + private final Long busNumber; + + public CityBusRemainTime(Long busNumber, LocalTime busArrivalTime) { + super(busArrivalTime, null); + this.busNumber = busNumber; + } + + public static CityBusRemainTime from(CityBusCacheInfo busInfo) { + return builder() + .busNumber(busInfo.busNumber()) + .busArrivalTime(busInfo.remainTime()) + .build(); + } + + @Override + public int hashCode() { + return Objects.hash(getBusArrivalTime()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CityBusRemainTime that = (CityBusRemainTime)o; + return Objects.equals(getBusArrivalTime(), that.getBusArrivalTime()) + && Objects.equals(busNumber, that.busNumber); + } + + @Override + public int compareTo(BusRemainTime o) { + return getBusArrivalTime().compareTo(o.getBusArrivalTime()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusApiType.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusApiType.java new file mode 100644 index 000000000..62089c562 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusApiType.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import java.util.Arrays; + +import in.koreatech.koin.domain.bus.exception.ApiTypeNotFoundException; +import lombok.Getter; + +@Getter +public enum BusApiType { + CITY, + EXPRESS, + ; + + public static BusApiType from(BusType value) { + return Arrays.stream(values()) + .filter(busApiType -> busApiType.toString().equalsIgnoreCase(value.toString())) + .findAny() + .orElseThrow(() -> ApiTypeNotFoundException.withDetail("apiType: " + value)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusDirection.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusDirection.java new file mode 100644 index 000000000..03597466b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusDirection.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import org.apache.commons.lang3.StringUtils; + +import in.koreatech.koin.domain.bus.exception.BusDirectionNotFoundException; + +public enum BusDirection { + /** + * 상행: 종합터미널 -> 병천 (한기대행) + */ + NORTH, + + /** + * 하행: 병천 -> 종합터미널 (천안역, 터미널행) + */ + SOUTH, + ; + + public static BusDirection from(String direction) { + if (StringUtils.equals(direction, "to")) { + return NORTH; + } + if (StringUtils.equals(direction, "from")) { + return SOUTH; + } + throw BusDirectionNotFoundException.withDetail("direction: " + direction); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusOpenApiResultCode.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusOpenApiResultCode.java new file mode 100644 index 000000000..f2a3ee19b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusOpenApiResultCode.java @@ -0,0 +1,47 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import java.util.Arrays; +import java.util.Optional; + +import com.google.gson.JsonObject; + +import in.koreatech.koin.domain.bus.exception.BusOpenApiException; +import lombok.Getter; + +@Getter +public enum BusOpenApiResultCode { + SERVICE_DISPOSE("12", "버스도착정보 공공 API 서비스가 폐기되었습니다."), + SERVICE_ACCESS_DENIED("20", "버스도착정보 공공 API 서비스가 접근 거부 상태입니다."), + SERVICE_REQUEST_OVER("22", "버스도착정보 공공 API 서비스의 요청 제한 횟수가 초과되었습니다."), + KEY_UNREGISTERED("30", "등록되지 않은 버스도착정보 공공 API 서비스 키입니다."), + SERVICE_KEY_EXPIRED("31", "버스도착정보 공공 API 서비스 키의 활용 기간이 만료되었습니다."), + SERVICE_SUCCESS("00", "NORMAL SERVICE."), + ; + + private final String code; + private final String message; + + BusOpenApiResultCode(String code, String message) { + this.code = code; + this.message = message; + } + + public static void validateResponse(JsonObject response) { + String resultCode = response.get("header").getAsJsonObject().get("resultCode").getAsString(); + + String errorMessage = ""; + + if (!resultCode.equals(SERVICE_SUCCESS.code)) { + Optional code = Arrays.stream(BusOpenApiResultCode.values()) + .filter(busOpenApiResultCode -> busOpenApiResultCode.code.equals(resultCode)) + .findFirst(); + + if (code.isPresent()) { + errorMessage = code.get().message; + } + + String resultMessage = response.get("header").getAsJsonObject().get("resultMsg").getAsString(); + throw BusOpenApiException.withDetail(errorMessage + " resultMsg: " + resultMessage); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java new file mode 100644 index 000000000..616ceac1b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import in.koreatech.koin.domain.bus.exception.BusStationNotFoundException; +import lombok.Getter; + +@Getter +public enum BusStation { + KOREATECH(List.of("학교", "한기대", "코리아텍"), BusStationNode.KOREATECH), + STATION(List.of("천안역", "천안역(학화호두과자)"), BusStationNode.STATION), + TERMINAL(List.of("터미널", "터미널(신세계 앞 횡단보도)", "야우리"), BusStationNode.TERMINAL), + ; + + private final List displayNames; + private final BusStationNode node; + + BusStation(List displayNames, BusStationNode node) { + this.displayNames = displayNames; + this.node = node; + } + + @JsonCreator + public static BusStation from(String busStationName) { + return Arrays.stream(values()) + .filter( + busStation -> busStation.name().equalsIgnoreCase(busStationName) || + busStation.displayNames.contains(busStationName) + ) + .findAny() + .orElseThrow(() -> BusStationNotFoundException.withDetail("busStation: " + busStationName)); + } + + public static BusDirection getDirection(BusStation depart, BusStation arrival) { + if (depart.ordinal() < arrival.ordinal()) { + return BusDirection.SOUTH; + } + return BusDirection.NORTH; + } + + public String getNodeId(BusDirection direction) { + return node.getId(direction); + } + + public String getName() { + return this.name().toLowerCase(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java new file mode 100644 index 000000000..4edb945fe --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import static in.koreatech.koin.domain.bus.model.enums.BusDirection.NORTH; +import static in.koreatech.koin.domain.bus.model.enums.BusDirection.SOUTH; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import lombok.Getter; + +/** + * OpenApi 상세: 국토교통부_전국 버스정류장 위치정보 (버스 정류장 노드 ID) + * https://www.data.go.kr/data/15067528/fileData.do + */ +@Getter +public enum BusStationNode { + TERMINAL(Map.of(NORTH, "CAB285000686", SOUTH, "CAB285000685")), // 종합터미널 + KOREATECH(Map.of(NORTH, "CAB285000406", SOUTH, "CAB285000405")), // 코리아텍 + STATION(Map.of(NORTH, "CAB285000655", SOUTH, "CAB285000656")), // 천안역 동부광장 + ; + + private final Map node; + + BusStationNode(Map node) { + this.node = node; + } + + public String getId(BusDirection direction) { + return node.get(direction); + } + + public static List getNodeIds() { + return Arrays.stream(values()) + .flatMap(station -> station.node.values().stream()) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusType.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusType.java new file mode 100644 index 000000000..927f2913b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusType.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import in.koreatech.koin.domain.bus.exception.BusTypeNotFoundException; +import lombok.Getter; + +@Getter +public enum BusType { + CITY("시내버스"), + EXPRESS("대성고속"), + SHUTTLE("셔틀버스"), + COMMUTING("통학버스"), + ; + + private final String label; + + BusType(String label) { + this.label = label; + } + + @JsonCreator + public static BusType from(String busTypeName) { + return Arrays.stream(values()) + .filter(busType -> busType.name().equalsIgnoreCase(busTypeName)) + .findAny() + .orElseThrow(() -> BusTypeNotFoundException.withDetail("busType: " + busTypeName)); + } + + public String getName() { + return this.name().toLowerCase(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCache.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCache.java new file mode 100644 index 000000000..54315e863 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCache.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.domain.bus.model.express; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; + +@Getter +@RedisHash(value = "expressBus") +public class ExpressBusCache { + + private static final long CACHE_EXPIRE_HOUR = 1L; + + @Id + private String id; + + private List busInfos; + + @TimeToLive(unit = TimeUnit.HOURS) + private final Long expiration; + + @Builder + private ExpressBusCache(String id, List busInfos, Long expiration) { + this.id = id; + this.busInfos = (busInfos == null) ? new ArrayList<>() : busInfos; + this.expiration = expiration == null ? CACHE_EXPIRE_HOUR : expiration; + } + + public static ExpressBusCache of(ExpressBusRoute route, List busInfos) { + return ExpressBusCache.builder() + .id(generateId(route)) + .busInfos(busInfos) + .build(); + } + + public static long getCacheExpireHour() { + return CACHE_EXPIRE_HOUR; + } + + public static String generateId(ExpressBusRoute route) { + return String.format("%s:%s", route.depTerminalName(), route.arrTerminalName()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCacheInfo.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCacheInfo.java new file mode 100644 index 000000000..15c1334c3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCacheInfo.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.domain.bus.model.express; + +import java.time.LocalTime; + +public record ExpressBusCacheInfo( + LocalTime depart, + LocalTime arrival, + int charge +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusRoute.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusRoute.java new file mode 100644 index 000000000..925da3fd3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusRoute.java @@ -0,0 +1,8 @@ +package in.koreatech.koin.domain.bus.model.express; + +public record ExpressBusRoute( + String depTerminalName, // 출발지 + String arrTerminalName // 도착지 +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusStationNode.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusStationNode.java new file mode 100644 index 000000000..d5c481729 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusStationNode.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.domain.bus.model.express; + +import java.util.Arrays; + +import in.koreatech.koin.domain.bus.exception.BusStationNotFoundException; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import lombok.Getter; + +/** + * OpenApi 상세: 국토교통부_전국 버스정류장 위치정보 (버스 정류장 노드 ID) + * https://www.data.go.kr/data/15067528/fileData.do + */ +@Getter +public enum ExpressBusStationNode { + KOREATECH(BusStation.KOREATECH, "NAI3125301"), // 코리아텍 + TERMINAL(BusStation.TERMINAL, "NAI3112001"), // 종합터미널 + ; + + private final BusStation busStation; + private final String stationId; + + ExpressBusStationNode(BusStation busStation, String stationId) { + this.busStation = busStation; + this.stationId = stationId; + } + + public static ExpressBusStationNode from(BusStation busStation) { + return Arrays.stream(values()) + .filter(it -> it.busStation.equals(busStation)) + .findAny() + .orElseThrow(() -> BusStationNotFoundException.withDetail("busStation: " + busStation)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusTimetable.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusTimetable.java new file mode 100644 index 000000000..1159c4f5b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusTimetable.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.domain.bus.model.express; + +import java.time.format.DateTimeFormatter; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.BusTimetable; +import lombok.Getter; + +@Getter +@JsonNaming(value = SnakeCaseStrategy.class) +public class ExpressBusTimetable extends BusTimetable { + + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + + @JsonProperty(value = "departure") + private final String depart; + + private final String arrival; + + private final int charge; + + public ExpressBusTimetable(String depart, String arrival, int charge) { + this.depart = depart; + this.arrival = arrival; + this.charge = charge; + } + + public static ExpressBusTimetable from(ExpressBusCacheInfo expressBusCacheInfo) { + String departure = expressBusCacheInfo.depart().format(TIME_FORMATTER); + String arrival = expressBusCacheInfo.arrival().format(TIME_FORMATTER); + int charge = expressBusCacheInfo.charge(); + return new ExpressBusTimetable(departure, arrival, charge); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/OpenApiExpressBusArrival.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/OpenApiExpressBusArrival.java new file mode 100644 index 000000000..02c7ffffb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/OpenApiExpressBusArrival.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.bus.model.express; + +import lombok.Builder; + +@Builder +public record OpenApiExpressBusArrival( + String arrPlaceNm, // 도착지 + String arrPlandTime, // 도착 시간 + String depPlaceNm, // 출발지 + String depPlandTime, // 출발 시간 + int charge, // 운임 요금 + String gradeNm, // 버스 등급 + String routeId // 노선 id +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/mongo/BusCourse.java b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/BusCourse.java new file mode 100644 index 000000000..6d492c75b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/BusCourse.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.domain.bus.model.mongo; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Document(collection = "bus_timetables") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BusCourse { + + @Id + @Field("_id") + private String id; + + @Field("bus_type") + private String busType; + + @Field("region") + private String region; + + @Field("direction") + private String direction; + + @Field("routes") + private List routes = new ArrayList<>(); + + @Builder + private BusCourse(String busType, String region, String direction, List routes) { + this.busType = busType; + this.region = region; + this.direction = direction; + this.routes = routes; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/mongo/Route.java b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/Route.java new file mode 100644 index 000000000..48aab2069 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/Route.java @@ -0,0 +1,93 @@ +package in.koreatech.koin.domain.bus.model.mongo; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.springframework.data.mongodb.core.mapping.Field; + +import in.koreatech.koin.domain.bus.exception.BusArrivalNodeNotFoundException; +import in.koreatech.koin.domain.bus.model.BusRemainTime; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Route { + + @Field("route_name") + private String routeName; + + @Field("running_days") + private List runningDays = new ArrayList<>(); + + @Field("arrival_info") + private List arrivalInfos = new ArrayList<>(); + + public boolean isRunning(Clock clock) { + if ("미운행".equals(routeName) || arrivalInfos.isEmpty()) { + return false; + } + String todayOfWeek = LocalDateTime.now(clock) + .getDayOfWeek() + .getDisplayName(TextStyle.SHORT, Locale.US) + .toUpperCase(); + return runningDays.contains(todayOfWeek); + } + + public boolean isCorrectRoute(BusStation depart, BusStation arrival, Clock clock) { + boolean foundDepart = false; + for (ArrivalNode node : arrivalInfos) { + if (depart.getDisplayNames().contains(node.getNodeName()) + && (BusRemainTime.from(node.getArrivalTime()).isBefore(clock))) { + foundDepart = true; + } + if (arrival.getDisplayNames().contains(node.getNodeName()) && foundDepart) { + return true; + } + } + return false; + } + + public BusRemainTime getRemainTime(BusStation busStation) { + ArrivalNode convertedNode = convertToArrivalNode(busStation); + return BusRemainTime.from(convertedNode.arrivalTime); + } + + private ArrivalNode convertToArrivalNode(BusStation busStation) { + return arrivalInfos.stream() + .filter(node -> busStation.getDisplayNames().contains(node.getNodeName())) + .findFirst() + .orElseThrow(() -> BusArrivalNodeNotFoundException.withDetail( + "routeName: " + routeName + ", busStation: " + busStation.name())); + } + + @Builder + private Route(String routeName, List runningDays, List arrivalInfos) { + this.routeName = routeName; + this.runningDays = runningDays; + this.arrivalInfos = arrivalInfos; + } + + @Getter + public static class ArrivalNode { + + @Field("node_name") + private String nodeName; + + @Field("arrival_time") + private String arrivalTime; + + @Builder + private ArrivalNode(String nodeName, String arrivalTime) { + this.nodeName = nodeName; + this.arrivalTime = arrivalTime; + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/redis/BusCache.java b/src/main/java/in/koreatech/koin/domain/bus/model/redis/BusCache.java new file mode 100644 index 000000000..bc511e729 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/redis/BusCache.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.domain.bus.model.redis; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +import in.koreatech.koin.domain.bus.model.BusRemainTime; +import in.koreatech.koin.domain.bus.model.city.CityBusArrival; + +public record BusCache( + Long busNumber, + LocalTime remainTime +) { + + /** + *
+     * 남은 시간 = (캐시 저장 시각 + 저장된 남은시간) - 현재시각
+     * {@link BusRemainTime#getRemainSeconds}에서는 남은 시간을 (저장된 남은시간 - 현재시각)으로 계산중
+     * 학교 버스는 도착 시간을 저장하고, 시내버스는 남은 시간만을 저장하므로
+     * Redis에 저장할때 (캐시 저장 시각 + 남은시간)으로 저장하여 통일시켜줌
+ * */ + public static BusCache of(CityBusArrival busArrivalInfo, LocalDateTime updatedAt) { + return new BusCache( + busArrivalInfo.routeno(), + updatedAt.plusSeconds(busArrivalInfo.arrtime()).toLocalTime() + ); + } + +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java new file mode 100644 index 000000000..f6a6ca3ff --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.bus.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.bus.exception.BusNotFoundException; +import in.koreatech.koin.domain.bus.model.mongo.BusCourse; + +public interface BusRepository extends Repository { + + BusCourse save(BusCourse busCourse); + + List findAll(); + + List findByBusType(String busType); + + Optional findByBusTypeAndDirectionAndRegion(String busType, String direction, String region); + + default BusCourse getByBusTypeAndDirectionAndRegion(String busType, String direction, String region) { + return findByBusTypeAndDirectionAndRegion(busType, direction, region).orElseThrow( + () -> BusNotFoundException.withDetail("region")); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusCacheRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusCacheRepository.java new file mode 100644 index 000000000..fa3bb1acf --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusCacheRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.bus.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.bus.exception.BusCacheNotFoundException; +import in.koreatech.koin.domain.bus.model.city.CityBusCache; + +public interface CityBusCacheRepository extends Repository { + + CityBusCache save(CityBusCache cityBusCache); + + List findAll(); + + Optional findById(String nodeId); + + default CityBusCache getById(String nodeId) { + return findById(nodeId).orElseThrow(() -> BusCacheNotFoundException.withDetail("nodeId: " + nodeId)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/ExpressBusCacheRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/ExpressBusCacheRepository.java new file mode 100644 index 000000000..476f3188e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/ExpressBusCacheRepository.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.bus.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.bus.exception.BusCacheNotFoundException; +import in.koreatech.koin.domain.bus.model.express.ExpressBusCache; + +public interface ExpressBusCacheRepository extends Repository { + + ExpressBusCache save(ExpressBusCache expressBusCache); + + Optional findById(String busRoute); + + default ExpressBusCache getById(String busRoute) { + return findById(busRoute).orElseThrow(() -> BusCacheNotFoundException.withDetail("busRoute: " + busRoute)); + } + + List findAll(); + + boolean existsById(String id); +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java new file mode 100644 index 000000000..7fc7c35e5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java @@ -0,0 +1,205 @@ +package in.koreatech.koin.domain.bus.service; + +import static in.koreatech.koin.domain.bus.model.enums.BusStation.STATION; +import static in.koreatech.koin.domain.bus.model.enums.BusStation.getDirection; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZonedDateTime; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.bus.dto.BusCourseResponse; +import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; +import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; +import in.koreatech.koin.domain.bus.exception.BusIllegalStationException; +import in.koreatech.koin.domain.bus.exception.BusTypeNotFoundException; +import in.koreatech.koin.domain.bus.exception.BusTypeNotSupportException; +import in.koreatech.koin.domain.bus.model.BusRemainTime; +import in.koreatech.koin.domain.bus.model.BusTimetable; +import in.koreatech.koin.domain.bus.model.SchoolBusTimetable; +import in.koreatech.koin.domain.bus.model.enums.BusDirection; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import in.koreatech.koin.domain.bus.model.enums.BusType; +import in.koreatech.koin.domain.bus.model.mongo.BusCourse; +import in.koreatech.koin.domain.bus.model.mongo.Route; +import in.koreatech.koin.domain.bus.repository.BusRepository; +import in.koreatech.koin.domain.bus.util.CityBusOpenApiClient; +import in.koreatech.koin.domain.bus.util.ExpressBusOpenApiClient; +import in.koreatech.koin.domain.version.dto.VersionResponse; +import in.koreatech.koin.domain.version.service.VersionService; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class BusService { + + private final Clock clock; + private final BusRepository busRepository; + private final CityBusOpenApiClient cityBusOpenApiClient; + private final ExpressBusOpenApiClient expressBusOpenApiClient; + private final VersionService versionService; + + @Transactional + public BusRemainTimeResponse getBusRemainTime(BusType busType, BusStation depart, BusStation arrival) { + // 출발지 == 도착지면 예외 + validateBusCourse(depart, arrival); + if (busType == BusType.CITY) { + // 시내버스에서 상행, 하행 구분할때 사용하는 로직 + BusDirection direction = getDirection(depart, arrival); + var remainTimes = cityBusOpenApiClient.getBusRemainTime(depart.getNodeId(direction)); + return toResponse(busType, remainTimes); + } + + if (busType == BusType.EXPRESS) { + var remainTimes = expressBusOpenApiClient.getBusRemainTime(depart, arrival); + return toResponse(busType, remainTimes); + } + + if (busType == BusType.SHUTTLE || busType == BusType.COMMUTING) { + List busCourses = busRepository.findByBusType(busType.getName()); + var remainTimes = busCourses.stream() + .map(BusCourse::getRoutes) + .flatMap(routes -> + routes.stream() + .filter(route -> route.isRunning(clock)) + .filter(route -> route.isCorrectRoute(depart, arrival, clock)) + .map(route -> route.getRemainTime(depart)) + ) + .distinct() + .sorted() + .toList(); + return toResponse(busType, remainTimes); + } + + throw new KoinIllegalArgumentException("Invalid bus", "type: " + busType); + } + + public List searchTimetable( + LocalDate date, LocalTime time, + BusStation depart, BusStation arrival + ) { + validateBusCourse(depart, arrival); + List result = new ArrayList<>(); + + LocalDateTime targetTime = LocalDateTime.of(date, time); + for (BusType busType : BusType.values()) { + SingleBusTimeResponse busTimeResponse = null; + + if (busType == BusType.EXPRESS && depart != STATION) { + busTimeResponse = expressBusOpenApiClient.searchBusTime( + busType.getName(), + depart, + arrival, + targetTime + ); + } + + if (busType == BusType.SHUTTLE || busType == BusType.COMMUTING) { + ZonedDateTime zonedAt = targetTime.atZone(clock.getZone()); + Clock clockAt = Clock.fixed(zonedAt.toInstant(), zonedAt.getZone()); + + String todayName = targetTime.getDayOfWeek() + .getDisplayName(TextStyle.SHORT, Locale.US) + .toUpperCase(); + + LocalTime arrivalTime = busRepository.findByBusType(busType.getName()).stream() + .filter(busCourse -> busCourse.getRegion().equals("천안")) + .map(BusCourse::getRoutes) + .flatMap(routes -> + routes.stream() + .filter(route -> route.getRunningDays().contains(todayName)) + .filter(route -> route.isRunning(clockAt)) + .filter(route -> route.isCorrectRoute(depart, arrival, clockAt)) + .flatMap(route -> + route.getArrivalInfos().stream() + .filter(arrivalNode -> depart.getDisplayNames().contains(arrivalNode.getNodeName())) + ) + ) + .min(Comparator.comparing(o -> LocalTime.parse(o.getArrivalTime()))) + .map(Route.ArrivalNode::getArrivalTime) + .map(LocalTime::parse) + .orElse(null); + + busTimeResponse = new SingleBusTimeResponse(busType.getName(), arrivalTime); + } + + if (busTimeResponse == null) { + continue; + } + result.add(busTimeResponse); + } + + return result; + } + + private BusRemainTimeResponse toResponse(BusType busType, List remainTimes) { + return BusRemainTimeResponse.of( + busType, + remainTimes.stream() + .filter(bus -> bus.getRemainSeconds(clock) != null) + .sorted(Comparator.naturalOrder()) + .toList(), + clock + ); + } + + private void validateBusCourse(BusStation depart, BusStation arrival) { + if (depart.equals(arrival)) { + throw BusIllegalStationException.withDetail("depart: " + depart.name() + ", arrival: " + arrival.name()); + } + } + + public List getBusTimetable(BusType busType, String direction, String region) { + if (busType == BusType.CITY) { + throw new BusTypeNotSupportException("CITY"); + } + + if (busType == BusType.EXPRESS) { + return expressBusOpenApiClient.getExpressBusTimetable(direction); + } + + if (busType == BusType.SHUTTLE || busType == BusType.COMMUTING) { + BusCourse busCourse = busRepository + .getByBusTypeAndDirectionAndRegion(busType.getName(), direction, region); + + return busCourse.getRoutes().stream() + .map(route -> new SchoolBusTimetable( + route.getRouteName(), + route.getArrivalInfos().stream() + .map(node -> new SchoolBusTimetable.ArrivalNode( + node.getNodeName(), node.getArrivalTime()) + ).toList())).toList(); + } + + throw new BusTypeNotFoundException(busType.name()); + } + + public BusTimetableResponse getBusTimetableWithUpdatedAt(BusType busType, String direction, String region) { + List busTimetables = getBusTimetable(busType, direction, region); + + if (busType.equals(BusType.COMMUTING)) { + busType = BusType.SHUTTLE; + } + + VersionResponse version = versionService.getVersion(busType.getName() + "_bus_timetable"); + return new BusTimetableResponse(busTimetables, version.updatedAt()); + } + + public List getBusCourses() { + return busRepository.findAll().stream() + .map(BusCourseResponse::from) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusOpenApiClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusOpenApiClient.java new file mode 100644 index 000000000..4b17b34b3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusOpenApiClient.java @@ -0,0 +1,193 @@ +package in.koreatech.koin.domain.bus.util; + +import static java.net.URLEncoder.encode; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Type; +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +import in.koreatech.koin.domain.bus.model.city.CityBusArrival; +import in.koreatech.koin.domain.bus.model.city.CityBusCache; +import in.koreatech.koin.domain.bus.model.city.CityBusCacheInfo; +import in.koreatech.koin.domain.bus.model.city.CityBusRemainTime; +import in.koreatech.koin.domain.bus.model.enums.BusOpenApiResultCode; +import in.koreatech.koin.domain.bus.model.enums.BusStationNode; +import in.koreatech.koin.domain.bus.repository.CityBusCacheRepository; +import in.koreatech.koin.domain.version.model.Version; +import in.koreatech.koin.domain.version.model.VersionType; +import in.koreatech.koin.domain.version.repository.VersionRepository; + +/** + * OpenApi 상세: 국토교통부_(TAGO)_버스도착정보 + * https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15098530 + */ +@Component +@Transactional(readOnly = true) +public class CityBusOpenApiClient { + + private static final String ENCODE_TYPE = "UTF-8"; + private static final String CHEONAN_CITY_CODE = "34010"; + private static final List AVAILABLE_CITY_BUS = List.of(400L, 402L, 405L); + private static final Type arrivalInfoType = new TypeToken>() { + }.getType(); + + private final String openApiKey; + private final Gson gson; + private final Clock clock; + private final VersionRepository versionRepository; + private final CityBusCacheRepository cityBusCacheRepository; + + public CityBusOpenApiClient( + @Value("${OPEN_API_KEY}") String openApiKey, + Gson gson, + Clock clock, + VersionRepository versionRepository, + CityBusCacheRepository cityBusCacheRepository + ) { + this.openApiKey = openApiKey; + this.gson = gson; + this.clock = clock; + this.versionRepository = versionRepository; + this.cityBusCacheRepository = cityBusCacheRepository; + } + + public List getBusRemainTime(String nodeId) { + Version version = versionRepository.getByType(VersionType.CITY); + if (isCacheExpired(version, clock) || cityBusCacheRepository.findById(nodeId).isEmpty()) { + storeRemainTimeByOpenApi(); + } + return getCityBusArrivalInfoByCache(nodeId); + } + + private List getCityBusArrivalInfoByCache(String nodeId) { + Optional cityBusCache = cityBusCacheRepository.findById(nodeId); + + return cityBusCache.map(busCache -> busCache.getBusInfos().stream().map(CityBusRemainTime::from).toList()) + .orElseGet(ArrayList::new); + } + + private void storeRemainTimeByOpenApi() { + List> arrivalInfosList = BusStationNode.getNodeIds().stream() + .map(this::getOpenApiResponse) + .map(this::extractBusArrivalInfo) + .map(cityBusArrivals -> cityBusArrivals.stream() + .filter(cityBusArrival -> + AVAILABLE_CITY_BUS.stream().anyMatch(busNumber -> + Objects.equals(busNumber, cityBusArrival.routeno())) + ).toList() + ).toList(); + + LocalDateTime updatedAt = LocalDateTime.now(clock); + + for (List arrivalInfos : arrivalInfosList) { + if (arrivalInfos.isEmpty()) { + continue; + } + + cityBusCacheRepository.save( + CityBusCache.of( + arrivalInfos.get(0).nodeid(), + arrivalInfos.stream() + .map(busArrivalInfo -> CityBusCacheInfo.of(busArrivalInfo, updatedAt)) + .toList() + ) + ); + } + + versionRepository.getByType(VersionType.CITY).update(clock); + } + + public String getOpenApiResponse(String nodeId) { + try { + URL url = new URL(getRequestURL(CHEONAN_CITY_CODE, nodeId)); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Content-type", "application/json"); + + BufferedReader input; + if (conn.getResponseCode() >= 200 && conn.getResponseCode() <= 300) { + input = new BufferedReader(new InputStreamReader(conn.getInputStream())); + } else { + input = new BufferedReader(new InputStreamReader(conn.getErrorStream())); + } + + StringBuilder response = new StringBuilder(); + String line; + while ((line = input.readLine()) != null) { + response.append(line); + } + input.close(); + conn.disconnect(); + return response.toString(); + } catch (IOException | NullPointerException e) { + return null; + } + } + + private String getRequestURL(String cityCode, String nodeId) throws UnsupportedEncodingException { + String url = "https://apis.data.go.kr/1613000/ArvlInfoInqireService/getSttnAcctoArvlPrearngeInfoList"; + String contentCount = "30"; + StringBuilder urlBuilder = new StringBuilder(url); + urlBuilder.append("?" + encode("serviceKey", ENCODE_TYPE) + "=" + encode(openApiKey, ENCODE_TYPE)); + urlBuilder.append("&" + encode("numOfRows", ENCODE_TYPE) + "=" + encode(contentCount, ENCODE_TYPE)); + urlBuilder.append("&" + encode("cityCode", ENCODE_TYPE) + "=" + encode(cityCode, ENCODE_TYPE)); + urlBuilder.append("&" + encode("nodeId", ENCODE_TYPE) + "=" + encode(nodeId, ENCODE_TYPE)); + urlBuilder.append("&_type=json"); + return urlBuilder.toString(); + } + + private List extractBusArrivalInfo(String jsonResponse) { + List result = new ArrayList<>(); + try { + JsonObject response = JsonParser.parseString(jsonResponse) + .getAsJsonObject() + .get("response") + .getAsJsonObject(); + BusOpenApiResultCode.validateResponse(response); + JsonObject body = response.get("body").getAsJsonObject(); + + if (body.get("totalCount").getAsLong() == 0) { + return result; + } + + JsonElement item = body.get("items").getAsJsonObject().get("item"); + if (item.isJsonArray()) { + return gson.fromJson(item, arrivalInfoType); + } + if (item.isJsonObject()) { + result.add(gson.fromJson(item, CityBusArrival.class)); + } + return result; + } catch (JsonSyntaxException e) { + return result; + } + } + + public boolean isCacheExpired(Version version, Clock clock) { + Duration duration = Duration.between(version.getUpdatedAt().toLocalTime(), LocalTime.now(clock)); + return duration.toSeconds() < 0 || CityBusCache.getCacheExpireSeconds() <= duration.toSeconds(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/ExpressBusOpenApiClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/ExpressBusOpenApiClient.java new file mode 100644 index 000000000..46924fd16 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/util/ExpressBusOpenApiClient.java @@ -0,0 +1,259 @@ +package in.koreatech.koin.domain.bus.util; + +import static in.koreatech.koin.domain.bus.model.enums.BusStation.KOREATECH; +import static in.koreatech.koin.domain.bus.model.enums.BusStation.TERMINAL; +import static in.koreatech.koin.domain.bus.model.enums.BusType.EXPRESS; +import static java.net.URLEncoder.encode; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.time.format.DateTimeFormatter.ofPattern; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.lang.reflect.Type; +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +import in.koreatech.koin.domain.bus.dto.ExpressBusRemainTime; +import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; +import in.koreatech.koin.domain.bus.exception.BusOpenApiException; +import in.koreatech.koin.domain.bus.model.BusRemainTime; +import in.koreatech.koin.domain.bus.model.BusTimetable; +import in.koreatech.koin.domain.bus.model.enums.BusOpenApiResultCode; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import in.koreatech.koin.domain.bus.model.express.ExpressBusCache; +import in.koreatech.koin.domain.bus.model.express.ExpressBusCacheInfo; +import in.koreatech.koin.domain.bus.model.express.ExpressBusRoute; +import in.koreatech.koin.domain.bus.model.express.ExpressBusStationNode; +import in.koreatech.koin.domain.bus.model.express.ExpressBusTimetable; +import in.koreatech.koin.domain.bus.model.express.OpenApiExpressBusArrival; +import in.koreatech.koin.domain.bus.repository.ExpressBusCacheRepository; +import in.koreatech.koin.domain.version.model.VersionType; +import in.koreatech.koin.domain.version.repository.VersionRepository; +import in.koreatech.koin.global.exception.KoinIllegalStateException; + +/** + * OpenApi 상세: 국토교통부_(TAGO)_버스도착정보 + * https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15098541 + */ +@Component +@Transactional(readOnly = true) +public class ExpressBusOpenApiClient { + + private static final String OPEN_API_URL = "https://apis.data.go.kr/1613000/SuburbsBusInfoService/getStrtpntAlocFndSuberbsBusInfo"; + private static final Type ARRIVAL_INFO_TYPE = new TypeToken>() { + }.getType(); + + private final VersionRepository versionRepository; + private final ExpressBusCacheRepository expressBusCacheRepository; + private final String openApiKey; + private final Gson gson; + private final Clock clock; + + public ExpressBusOpenApiClient( + @Value("${OPEN_API_KEY}") String openApiKey, + VersionRepository versionRepository, + Gson gson, + Clock clock, + ExpressBusCacheRepository expressBusCacheRepository + ) { + this.openApiKey = openApiKey; + this.versionRepository = versionRepository; + this.gson = gson; + this.clock = clock; + this.expressBusCacheRepository = expressBusCacheRepository; + } + + public SingleBusTimeResponse searchBusTime( + String busType, + BusStation depart, BusStation arrival, + LocalDateTime targetTime + ) { + List remainTimes = getBusRemainTime(depart, arrival); + if (remainTimes.isEmpty()) { + return null; + } + + LocalTime arrivalTime = remainTimes.stream() + .filter(expressBusRemainTime -> targetTime.toLocalTime().isBefore(expressBusRemainTime.getBusArrivalTime())) + .min(Comparator.naturalOrder()) + .map(BusRemainTime::getBusArrivalTime) + .orElse(null); + + return new SingleBusTimeResponse(busType, arrivalTime); + } + + public List getBusRemainTime(BusStation depart, BusStation arrival) { + String busCacheId = ExpressBusCache.generateId( + new ExpressBusRoute(depart.getName(), arrival.getName())); + if (!expressBusCacheRepository.existsById(busCacheId)) { + storeRemainTimeByOpenApi(depart, arrival); + } + return getStoredRemainTime(busCacheId); + } + + private void storeRemainTimeByOpenApi(BusStation depart, BusStation arrival) { + JsonObject busApiResponse = getBusApiResponse(depart, arrival); + List busArrivals = extractBusArrivalInfo(busApiResponse); + + ExpressBusCache expressBusCache = ExpressBusCache.of( + new ExpressBusRoute(depart.getName(), arrival.getName()), + // API로 받은 yyyyMMddHHmm 형태의 시간을 HH:mm 형태로 변환하여 Redis에 저장한다. + busArrivals.stream() + .map(it -> new ExpressBusCacheInfo( + LocalTime.parse( + LocalDateTime.parse(it.depPlandTime(), ofPattern("yyyyMMddHHmm")) + .format(ofPattern("HH:mm")) + ), + LocalTime.parse( + LocalDateTime.parse(it.arrPlandTime(), ofPattern("yyyyMMddHHmm")) + .format(ofPattern("HH:mm")) + ), + it.charge() + )) + .toList() + ); + + if (!expressBusCache.getBusInfos().isEmpty()) { + expressBusCacheRepository.save(expressBusCache); + } + + versionRepository.getByType(VersionType.EXPRESS).update(clock); + } + + private JsonObject getBusApiResponse(BusStation depart, BusStation arrival) { + try { + URL url = getBusApiURL(depart, arrival); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Content-type", "application/json"); + BufferedReader reader; + if (conn.getResponseCode() >= 200 && conn.getResponseCode() <= 300) { + reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); + } else { + reader = new BufferedReader(new InputStreamReader(conn.getErrorStream())); + } + StringBuilder result = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + result.append(line); + } + reader.close(); + conn.disconnect(); + return JsonParser.parseString(result.toString()) + .getAsJsonObject(); + } catch (Exception ignore) { + throw BusOpenApiException.withDetail("depart: " + depart + " arrival: " + arrival); + } + } + + private URL getBusApiURL(BusStation depart, BusStation arrival) { + ExpressBusStationNode departNode = ExpressBusStationNode.from(depart); + ExpressBusStationNode arrivalNode = ExpressBusStationNode.from(arrival); + StringBuilder urlBuilder = new StringBuilder(OPEN_API_URL); /*URL*/ + try { + urlBuilder.append("?" + encode("serviceKey", UTF_8) + "=" + encode(openApiKey, UTF_8)); + urlBuilder.append("&" + encode("numOfRows", UTF_8) + "=" + encode("30", UTF_8)); + urlBuilder.append("&" + encode("_type", UTF_8) + "=" + encode("json", UTF_8)); + urlBuilder.append("&" + encode("depTerminalId", UTF_8) + "=" + encode(departNode.getStationId(), UTF_8)); + urlBuilder.append("&" + encode("arrTerminalId", UTF_8) + "=" + encode(arrivalNode.getStationId(), UTF_8)); + urlBuilder.append("&" + encode("depPlandTime", UTF_8) + "=" + + encode(LocalDateTime.now(clock).format(ofPattern("yyyyMMdd")), UTF_8)); + return new URL(urlBuilder.toString()); + } catch (Exception e) { + throw new KoinIllegalStateException("시외버스 API URL 생성중 문제가 발생했습니다.", "uri:" + urlBuilder); + } + } + + private List extractBusArrivalInfo(JsonObject jsonObject) { + try { + var response = jsonObject.get("response").getAsJsonObject(); + BusOpenApiResultCode.validateResponse(response); + JsonObject body = response.get("body").getAsJsonObject(); + if (body.get("totalCount").getAsLong() == 0) { + return Collections.emptyList(); + } + JsonElement item = body.get("items").getAsJsonObject().get("item"); + List result = new ArrayList<>(); + if (item.isJsonArray()) { + return gson.fromJson(item, ARRIVAL_INFO_TYPE); + } + if (item.isJsonObject()) { + result.add(gson.fromJson(item, OpenApiExpressBusArrival.class)); + } + return result; + } catch (JsonSyntaxException e) { + return Collections.emptyList(); + } + } + + private List getStoredRemainTime(String busCacheId) { + ExpressBusCache expressBusCache = expressBusCacheRepository.getById(busCacheId); + if (Objects.isNull(expressBusCache)) { + return Collections.emptyList(); + } + List busArrivals = expressBusCache.getBusInfos(); + return getExpressBusRemainTime(busArrivals); + } + + private List getExpressBusRemainTime( + List busArrivals + ) { + return busArrivals.stream() + .map(it -> new ExpressBusRemainTime(it.depart(), EXPRESS.getName())) + .toList(); + } + + public List getExpressBusTimetable(String direction) { + BusStation depart = null; + BusStation arrival = null; + + if ("from".equals(direction)) { + depart = KOREATECH; + arrival = TERMINAL; + } + if ("to".equals(direction)) { + depart = TERMINAL; + arrival = KOREATECH; + } + + if (depart == null || arrival == null) { + throw new UnsupportedOperationException(); + } + + String busCacheId = ExpressBusCache.generateId( + new ExpressBusRoute(depart.getName(), arrival.getName())); + if (!expressBusCacheRepository.existsById(busCacheId)) { + storeRemainTimeByOpenApi(depart, arrival); + } + + ExpressBusCache expressBusCache = expressBusCacheRepository.getById(busCacheId); + if (Objects.isNull(expressBusCache)) { + return Collections.emptyList(); + } + List busArrivals = expressBusCache.getBusInfos(); + + return busArrivals + .stream() + .map(ExpressBusTimetable::from) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/controller/CommunityApi.java b/src/main/java/in/koreatech/koin/domain/community/controller/CommunityApi.java new file mode 100644 index 000000000..a383f585f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/controller/CommunityApi.java @@ -0,0 +1,65 @@ +package in.koreatech.koin.domain.community.controller; + +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import java.util.List; + +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.RequestParam; + +import in.koreatech.koin.domain.community.dto.ArticleResponse; +import in.koreatech.koin.domain.community.dto.ArticlesResponse; +import in.koreatech.koin.domain.community.dto.HotArticleItemResponse; +import in.koreatech.koin.global.auth.UserId; +import in.koreatech.koin.global.ipaddress.IpAddress; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Community: 커뮤니티", description = "커뮤니티 정보를 관리한다") +public interface CommunityApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "게시글 단건 조회") + @GetMapping("/articles/{id}") + ResponseEntity getArticle( + @UserId Integer userId, + @Parameter(in = PATH) @PathVariable("id") Integer articleId, + @IpAddress String ipAddress + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "게시글 목록 조회") + @GetMapping("/articles") + ResponseEntity getArticles( + @RequestParam Integer boardId, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "인기 게시글 목록 조회") + @GetMapping("/articles/hot/list") + ResponseEntity> getHotArticles(); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/controller/CommunityController.java b/src/main/java/in/koreatech/koin/domain/community/controller/CommunityController.java new file mode 100644 index 000000000..e6ca6601a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/controller/CommunityController.java @@ -0,0 +1,50 @@ +package in.koreatech.koin.domain.community.controller; + +import java.util.List; + +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.community.dto.ArticleResponse; +import in.koreatech.koin.domain.community.dto.ArticlesResponse; +import in.koreatech.koin.domain.community.dto.HotArticleItemResponse; +import in.koreatech.koin.domain.community.service.CommunityService; +import in.koreatech.koin.global.auth.UserId; +import in.koreatech.koin.global.ipaddress.IpAddress; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class CommunityController implements CommunityApi { + + private final CommunityService communityService; + + @GetMapping("/articles/{id}") + public ResponseEntity getArticle( + @UserId Integer userId, + @PathVariable("id") Integer articleId, + @IpAddress String ipAddress + ) { + ArticleResponse foundArticle = communityService.getArticle(userId, articleId, ipAddress); + return ResponseEntity.ok().body(foundArticle); + } + + @GetMapping("/articles") + public ResponseEntity getArticles( + @RequestParam Integer boardId, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit + ) { + ArticlesResponse foundArticles = communityService.getArticles(boardId, page, limit); + return ResponseEntity.ok().body(foundArticles); + } + + @GetMapping("/articles/hot/list") + public ResponseEntity> getHotArticles() { + List hotArticles = communityService.getHotArticles(); + return ResponseEntity.ok().body(hotArticles); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/dto/ArticleResponse.java b/src/main/java/in/koreatech/koin/domain/community/dto/ArticleResponse.java new file mode 100644 index 000000000..9ffcc8f9d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/dto/ArticleResponse.java @@ -0,0 +1,193 @@ +package in.koreatech.koin.domain.community.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.model.Article; +import in.koreatech.koin.domain.community.model.Board; +import in.koreatech.koin.domain.community.model.Comment; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record ArticleResponse( + @Schema(description = "게시글 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "게시판 고유 ID", example = "1", requiredMode = REQUIRED) + Integer boardId, + + @Schema(description = "제목", example = "제목", requiredMode = REQUIRED) + String title, + + @Schema(description = "내용", example = "내용", requiredMode = REQUIRED) + String content, + + @Schema(description = "작성자 닉네임", example = "닉네임", requiredMode = REQUIRED) + String nickname, + + @Schema(description = "해결 여부", example = "false", requiredMode = REQUIRED) + boolean isSolved, + + @Schema(description = "공지 여부", example = "false", requiredMode = REQUIRED) + boolean isNotice, + + @Schema(description = "내용 요약", example = "내용 요약", requiredMode = NOT_REQUIRED) + @JsonProperty("contentSummary") String contentSummary, + + @Schema(description = "조회수", example = "1", requiredMode = REQUIRED) + int hit, + + @Schema(description = "댓글 수", example = "1", requiredMode = REQUIRED) + int commentCount, + + @Schema(description = "게시판 정보", requiredMode = REQUIRED) + InnerBoardResponse board, + + @Schema(description = "댓글 목록", requiredMode = NOT_REQUIRED) + List comments, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt +) { + + public static ArticleResponse of(Article article) { + return new ArticleResponse( + article.getId(), + article.getBoard().getId(), + article.getTitle(), + article.getContent(), + article.getNickname(), + article.isSolved(), + article.isNotice(), + article.getContentSummary(), + article.getHit(), + article.getCommentCount(), + InnerBoardResponse.from(article.getBoard()), + article.getComment().stream().map(InnerCommentResponse::from).toList(), + article.getCreatedAt(), + article.getUpdatedAt() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerBoardResponse( + @Schema(description = "게시판 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "게시판 태그", example = "tag", requiredMode = REQUIRED) + String tag, + + @Schema(description = "게시판 이름", example = "게시판 이름", requiredMode = REQUIRED) + String name, + + @Schema(description = "익명 여부", example = "false", requiredMode = REQUIRED) + boolean isAnonymous, + + @Schema(description = "게시글 수", example = "1", requiredMode = REQUIRED) + int articleCount, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + boolean isDeleted, + + @Schema(description = "공지 여부", example = "false", requiredMode = REQUIRED) + boolean isNotice, + + @Schema(description = "부모 게시판 고유 ID", example = "1", requiredMode = NOT_REQUIRED) + Integer parentId, + + @Schema(description = "순서", example = "1", requiredMode = REQUIRED) + int seq, + + @Schema(description = "하위 게시판 목록", requiredMode = NOT_REQUIRED) + List children, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime updatedAt + ) { + + public static InnerBoardResponse from(Board board) { + return new InnerBoardResponse( + board.getId(), + board.getTag(), + board.getName(), + board.getIsAnonymous(), + board.getArticleCount(), + board.isDeleted(), + board.isNotice(), + board.getParentId(), + board.getSeq(), + board.getChildren().isEmpty() + ? null : board.getChildren().stream().map(InnerBoardResponse::from).toList(), + board.getCreatedAt(), + board.getUpdatedAt() + ); + } + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerCommentResponse( + @Schema(description = "댓글 고유 ID", example = "1", requiredMode = NOT_REQUIRED) + Integer id, + + @Schema(description = "게시글 고유 ID", example = "1", requiredMode = REQUIRED) + Integer articleId, + + @Schema(description = "내용", example = "내용", requiredMode = REQUIRED) + String content, + + @Schema(description = "작성자 고유 ID", example = "1", requiredMode = REQUIRED) + Integer userId, + + @Schema(description = "작성자 닉네임", example = "닉네임", requiredMode = REQUIRED) + String nickname, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + boolean isDeleted, + + @Schema(description = "수정 권한", example = "false", requiredMode = REQUIRED) + @JsonProperty("grantEdit") + boolean grantEdit, + + @Schema(description = "삭제 권한", example = "false", requiredMode = REQUIRED) + @JsonProperty("grantDelete") + boolean grantDelete, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt + ) { + + public static InnerCommentResponse from(Comment comment) { + return new InnerCommentResponse( + comment.getId(), + comment.getArticle().getId(), + comment.getContent(), + comment.getUserId(), + comment.getNickname(), + comment.getIsDeleted(), + comment.isGrantEdit(), + comment.isGrantDelete(), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/dto/ArticlesResponse.java b/src/main/java/in/koreatech/koin/domain/community/dto/ArticlesResponse.java new file mode 100644 index 000000000..2c981edd4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/dto/ArticlesResponse.java @@ -0,0 +1,185 @@ +package in.koreatech.koin.domain.community.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.model.Article; +import in.koreatech.koin.domain.community.model.Board; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ArticlesResponse( + @Schema(description = "게시글 목록", requiredMode = NOT_REQUIRED) + List articles, + + @Schema(description = "게시판 정보", requiredMode = REQUIRED) + InnerBoardResponse board, + + @Schema(description = "총 페이지 수", example = "1", requiredMode = REQUIRED) + long totalPage +) { + + public static ArticlesResponse of(List
articles, Board board, Long totalPage) { + return new ArticlesResponse( + articles.stream() + .map(InnerArticleResponse::from) + .toList(), + InnerBoardResponse.from(board), + totalPage + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerArticleResponse( + + @Schema(description = "게시글 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "게시판 고유 ID", example = "1", requiredMode = REQUIRED) + Integer boardId, + + @Schema(description = "제목", example = "제목", requiredMode = REQUIRED) + String title, + + @Schema(description = "내용", example = "내용", requiredMode = REQUIRED) + String content, + + @Schema(description = "작성자 고유 ID", example = "1", requiredMode = REQUIRED) + int userId, + + @Schema(description = "작성자 닉네임", example = "닉네임", requiredMode = REQUIRED) + String nickname, + + @Schema(description = "조회수", example = "1", requiredMode = REQUIRED) + int hit, + + @Schema(description = "IP 주소", example = "123.12.1.3") + String ip, + + @Schema(description = "해결 여부", example = "false") + boolean isSolved, + + @Schema(description = "삭제 여부", example = "false") + boolean isDeleted, + + @Schema(description = "댓글 수", example = "1") + int commentCount, + + @Schema(description = "메타 정보", example = "메타 정보", requiredMode = NOT_REQUIRED) + String meta, + + @Schema(description = "공지 여부", example = "false", requiredMode = REQUIRED) + boolean isNotice, + + @Schema(description = "공지 게시글 고유 ID", example = "1", requiredMode = NOT_REQUIRED) + Integer noticeArticleId, + + @Schema(description = "요약", example = "요약", requiredMode = NOT_REQUIRED) + String summary, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime updatedAt, + + @Schema(description = "내용 요약", example = "내용 요약", requiredMode = NOT_REQUIRED) + @JsonProperty("contentSummary") + String contentSummary + ) { + + public static InnerArticleResponse from(Article article) { + return new InnerArticleResponse( + article.getId(), + article.getBoard().getId(), + article.getTitle(), + article.getContent(), + article.getUser().getId(), + article.getNickname(), + article.getHit(), + article.getIp(), + article.isSolved(), + article.isDeleted(), + article.getCommentCount(), + article.getMeta(), + article.isNotice(), + article.getNoticeArticleId(), + article.getSummary(), + article.getCreatedAt(), + article.getUpdatedAt(), + article.getContentSummary() + ); + } + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerBoardResponse( + @Schema(description = "게시판 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "게시판 태그", example = "notice", requiredMode = NOT_REQUIRED) + String tag, + + @Schema(description = "게시판 명", example = "공지사항", requiredMode = REQUIRED) + String name, + + @Schema(description = "익명 여부", example = "false", requiredMode = REQUIRED) + boolean isAnonymous, + + @Schema(description = "게시글 수", example = "1", requiredMode = REQUIRED) + int articleCount, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + boolean isDeleted, + + @Schema(description = "공지 여부", example = "false", requiredMode = REQUIRED) + boolean isNotice, + + @Schema(description = "부모 게시판 고유 ID", example = "1", requiredMode = NOT_REQUIRED) + Integer parentId, + + @Schema(description = "순서", example = "1", requiredMode = REQUIRED) + int seq, + + @Schema(description = "하위 게시판 목록", requiredMode = NOT_REQUIRED) + List children, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime updatedAt + ) { + + public static InnerBoardResponse from(Board board) { + return new InnerBoardResponse( + board.getId(), + board.getTag(), + board.getName(), + board.getIsAnonymous(), + board.getArticleCount(), + board.isDeleted(), + board.isNotice(), + board.getParentId(), + board.getSeq(), + board.getChildren().isEmpty() + ? null : board.getChildren().stream() + .map(InnerBoardResponse::from) + .toList(), + board.getCreatedAt(), + board.getUpdatedAt() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/dto/HotArticleItemResponse.java b/src/main/java/in/koreatech/koin/domain/community/dto/HotArticleItemResponse.java new file mode 100644 index 000000000..2e370685a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/dto/HotArticleItemResponse.java @@ -0,0 +1,53 @@ +package in.koreatech.koin.domain.community.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.model.Article; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record HotArticleItemResponse( + @Schema(description = "게시글 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "게시판 고유 ID", example = "1", requiredMode = REQUIRED) + Integer boardId, + + @Schema(description = "제목", example = "제목", requiredMode = REQUIRED) + String title, + + @Schema(description = "내용 요약", example = "내용 요약", requiredMode = NOT_REQUIRED) + @JsonProperty("contentSummary") + String contentSummary, + + @Schema(description = "댓글 수", example = "1", requiredMode = REQUIRED) + Byte commentCount, + + @Schema(description = "조회수", example = "1", requiredMode = REQUIRED) + Integer hit, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt +) { + + public static HotArticleItemResponse from(Article article) { + return new HotArticleItemResponse( + article.getId(), + article.getBoard().getId(), + article.getTitle(), + article.getContentSummary(), + article.getCommentCount(), + article.getHit(), + article.getCreatedAt() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/exception/ArticleNotFoundException.java b/src/main/java/in/koreatech/koin/domain/community/exception/ArticleNotFoundException.java new file mode 100644 index 000000000..8a30cd5ae --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/exception/ArticleNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.community.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class ArticleNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "게시글이 존재하지 않습니다."; + + public ArticleNotFoundException(String message) { + super(message); + } + + public ArticleNotFoundException(String message, String detail) { + super(message, detail); + } + + public static ArticleNotFoundException withDetail(String detail) { + return new ArticleNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/model/Article.java b/src/main/java/in/koreatech/koin/domain/community/model/Article.java new file mode 100644 index 000000000..75a5015bd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/model/Article.java @@ -0,0 +1,157 @@ +package in.koreatech.koin.domain.community.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.Where; +import org.jsoup.Jsoup; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.PostLoad; +import jakarta.persistence.PostPersist; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "articles") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class Article extends BaseEntity { + + private static final int SUMMARY_MIN_LENGTH = 0; + private static final int SUMMARY_MAX_LENGTH = 100; + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id", nullable = false) + private Board board; + + @Size(max = 255) + @NotNull + @Column(name = "title", nullable = false) + private String title; + + @NotNull + @Column(name = "content", nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Size(max = 50) + @NotNull + @Column(name = "nickname", nullable = false, length = 50) + private String nickname; + + @NotNull + @Column(name = "hit", nullable = false) + private int hit; + + @Size(max = 45) + @NotNull + @Column(name = "ip", nullable = false, length = 45) + private String ip; + + @NotNull + @Column(name = "is_solved", nullable = false) + private boolean isSolved = false; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @OneToMany(mappedBy = "article", fetch = FetchType.LAZY) + private List comment = new ArrayList<>(); + + @NotNull + @Column(name = "comment_count", nullable = false) + private Byte commentCount; + + @Column(name = "meta") + private String meta; + + @NotNull + @Column(name = "is_notice", nullable = false) + private boolean isNotice = false; + + @Column(name = "notice_article_id", unique = true) + private Integer noticeArticleId; + + @Transient + private String summary; + + @Transient + private String contentSummary; + + @PostPersist + @PostLoad + public void updateContentSummary() { + if (content == null) { + contentSummary = ""; + return; + } + String parseResult = Jsoup.parse(content).text().replace(" ", "").strip(); + if (parseResult.length() < SUMMARY_MAX_LENGTH) { + contentSummary = parseResult; + return; + } + contentSummary = parseResult.substring(SUMMARY_MIN_LENGTH, SUMMARY_MAX_LENGTH); + } + + public void increaseHit() { + hit++; + } + + @Builder + private Article( + Board board, + String title, + String content, + User user, + String nickname, + Integer hit, + String ip, + boolean isSolved, + boolean isDeleted, + Byte commentCount, + String meta, + boolean isNotice, + Integer noticeArticleId + ) { + this.board = board; + this.title = title; + this.content = content; + this.user = user; + this.nickname = nickname; + this.hit = hit; + this.ip = ip; + this.isSolved = isSolved; + this.isDeleted = isDeleted; + this.commentCount = commentCount; + this.meta = meta; + this.isNotice = isNotice; + this.noticeArticleId = noticeArticleId; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/model/ArticleViewLog.java b/src/main/java/in/koreatech/koin/domain/community/model/ArticleViewLog.java new file mode 100644 index 000000000..38d9511f8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/model/ArticleViewLog.java @@ -0,0 +1,68 @@ +package in.koreatech.koin.domain.community.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "article_view_logs", uniqueConstraints = { + @UniqueConstraint( + name = "article_view_logs_article_id_user_id_unique", + columnNames = {"article_id", "user_id"} + ) +}) +@NoArgsConstructor(access = PROTECTED) +public class ArticleViewLog { + + private static final Long EXPIRED_HOUR = 1L; + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @OneToOne + @JoinColumn(name = "article_id", nullable = false) + private Article article; + + @OneToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @NotNull + @Column(name = "expired_at", columnDefinition = "TIMESTAMP", nullable = false) + private LocalDateTime expiredAt = LocalDateTime.now().plusHours(EXPIRED_HOUR); + + @Size(max = 45) + @NotNull + @Column(name = "ip", nullable = false, length = 45) + private String ip; + + public void updateExpiredTime() { + expiredAt = LocalDateTime.now().plusHours(EXPIRED_HOUR); + } + + @Builder + private ArticleViewLog(Article article, User user, String ip) { + this.article = article; + this.user = user; + this.ip = ip; + updateExpiredTime(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/model/Board.java b/src/main/java/in/koreatech/koin/domain/community/model/Board.java new file mode 100644 index 000000000..fc60d0008 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/model/Board.java @@ -0,0 +1,91 @@ +package in.koreatech.koin.domain.community.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "boards") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class Board extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Size(max = 10) + @NotNull + @Column(name = "tag", nullable = false, length = 10, unique = true) + private String tag; + + @Size(max = 50) + @NotNull + @Column(name = "name", nullable = false, length = 50) + private String name; + + @NotNull + @Column(name = "is_anonymous", nullable = false) + private Boolean isAnonymous = false; + + @NotNull + @Column(name = "article_count", nullable = false) + private Integer articleCount; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @NotNull + @Column(name = "is_notice", nullable = false) + private boolean isNotice = false; + + @Column(name = "parent_id") + private Integer parentId; + + @NotNull + @Column(name = "seq", nullable = false) + private Integer seq; + + public List getChildren() { + return new ArrayList<>(); + } + + @Builder + private Board( + String tag, + String name, + boolean isAnonymous, + Integer articleCount, + boolean isDeleted, + boolean isNotice, + Integer parentId, + Integer seq + ) { + this.tag = tag; + this.name = name; + this.isAnonymous = isAnonymous; + this.articleCount = articleCount; + this.isDeleted = isDeleted; + this.isNotice = isNotice; + this.parentId = parentId; + this.seq = seq; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/model/BoardTag.java b/src/main/java/in/koreatech/koin/domain/community/model/BoardTag.java new file mode 100644 index 000000000..2344895c5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/model/BoardTag.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.community.model; + +import lombok.Getter; + +@Getter +public enum BoardTag { + 자유게시판("FA001"), + 취업게시판("JA001"), + 익명게시판("AA001"), + 공지사항("NA000"), + 일반공지("NA001"), + 장학공지("NA002"), + 학사공지("NA003"), + 취업공지("NA004"), + 코인공지("NA005"), + 질문게시판("QA001"), + 홍보게시판("EA001"), + ; + + private final String tag; + + BoardTag(String tag) { + this.tag = tag; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/model/Comment.java b/src/main/java/in/koreatech/koin/domain/community/model/Comment.java new file mode 100644 index 000000000..6c72aebf3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/model/Comment.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.community.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "comments") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class Comment extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", nullable = false) + private Article article; + + @NotNull + @Column(name = "content", nullable = false) + private String content; + + @NotNull + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Size(max = 50) + @NotNull + @Column(name = "nickname", nullable = false, length = 50) + private String nickname; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @Transient + private boolean grantEdit = false; + + @Transient + private boolean grantDelete = false; + + public void updateAuthority(Integer userId) { + if (this.userId.equals(userId)) { + this.grantEdit = true; + this.grantDelete = true; + } + } + + @Builder + private Comment(Article article, String content, Integer userId, + String nickname, Boolean isDeleted) { + this.article = article; + this.content = content; + this.userId = userId; + this.nickname = nickname; + this.isDeleted = isDeleted; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/repository/ArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/repository/ArticleRepository.java new file mode 100644 index 000000000..12bb56601 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/repository/ArticleRepository.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.domain.community.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.community.exception.ArticleNotFoundException; +import in.koreatech.koin.domain.community.model.Article; + +public interface ArticleRepository extends Repository { + + Article save(Article article); + + Page
findByIsNotice(Boolean isNotice, Pageable pageable); + + Optional
findById(Integer articleId); + + List
findAll(Pageable pageable); + + Page
findByBoardId(Integer boardId, PageRequest pageRequest); + + default Article getById(Integer articleId) { + return findById(articleId).orElseThrow( + () -> ArticleNotFoundException.withDetail( + "articleId: " + articleId)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/repository/ArticleViewLogRepository.java b/src/main/java/in/koreatech/koin/domain/community/repository/ArticleViewLogRepository.java new file mode 100644 index 000000000..6543ac514 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/repository/ArticleViewLogRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.community.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.community.model.ArticleViewLog; + +public interface ArticleViewLogRepository extends Repository { + + Optional findByArticleIdAndUserId(Integer articleId, Integer userId); + + ArticleViewLog save(ArticleViewLog articleViewLog); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/repository/BoardRepository.java b/src/main/java/in/koreatech/koin/domain/community/repository/BoardRepository.java new file mode 100644 index 000000000..68a3914aa --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/repository/BoardRepository.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.community.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.community.exception.ArticleNotFoundException; +import in.koreatech.koin.domain.community.model.Board; + +public interface BoardRepository extends Repository { + Optional findById(Integer id); + + Board save(Board board); + + default Board getById(Integer boardId) { + return findById(boardId).orElseThrow( + () -> ArticleNotFoundException.withDetail("boardId: " + boardId)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/repository/CommentRepository.java b/src/main/java/in/koreatech/koin/domain/community/repository/CommentRepository.java new file mode 100644 index 000000000..7ff83faf8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/repository/CommentRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.community.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.community.model.Comment; + +public interface CommentRepository extends Repository { + + List findAllByArticleId(Integer articleId); + + Comment save(Comment comment); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/service/CommunityService.java b/src/main/java/in/koreatech/koin/domain/community/service/CommunityService.java new file mode 100644 index 000000000..eb5c3fdda --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/service/CommunityService.java @@ -0,0 +1,95 @@ +package in.koreatech.koin.domain.community.service; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.community.dto.ArticleResponse; +import in.koreatech.koin.domain.community.dto.ArticlesResponse; +import in.koreatech.koin.domain.community.dto.HotArticleItemResponse; +import in.koreatech.koin.domain.community.model.Article; +import in.koreatech.koin.domain.community.model.ArticleViewLog; +import in.koreatech.koin.domain.community.model.Board; +import in.koreatech.koin.domain.community.model.BoardTag; +import in.koreatech.koin.domain.community.repository.ArticleRepository; +import in.koreatech.koin.domain.community.repository.ArticleViewLogRepository; +import in.koreatech.koin.domain.community.repository.BoardRepository; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.model.Criteria; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommunityService { + + private static final int HOT_ARTICLE_LIMIT = 10; + private static final Sort ARTICLES_SORT = Sort.by(Sort.Direction.DESC, "id"); + + private final ArticleRepository articleRepository; + private final ArticleViewLogRepository articleViewLogRepository; + private final BoardRepository boardRepository; + private final UserRepository userRepository; + + @Transactional + public ArticleResponse getArticle(Integer userId, Integer articleId, String ipAddress) { + Article article = articleRepository.getById(articleId); + if (isHittable(articleId, userId, ipAddress)) { + article.increaseHit(); + } + article.getComment().forEach(comment -> comment.updateAuthority(userId)); + return ArticleResponse.of(article); + } + + private boolean isHittable(Integer articleId, Integer userId, String ipAddress) { + if (userId == null) { + return false; + } + LocalDateTime now = LocalDateTime.now(); + Optional foundLog = articleViewLogRepository.findByArticleIdAndUserId(articleId, userId); + if (foundLog.isEmpty()) { + articleViewLogRepository.save( + ArticleViewLog.builder() + .article(articleRepository.getById(articleId)) + .user(userRepository.getById(userId)) + .ip(ipAddress) + .build() + ); + return true; + } + if (now.isAfter(foundLog.get().getExpiredAt())) { + foundLog.get().updateExpiredTime(); + return true; + } + return false; + } + + public ArticlesResponse getArticles(Integer boardId, Integer page, Integer limit) { + Criteria criteria = Criteria.of(page, limit); + Board board = boardRepository.getById(boardId); + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), ARTICLES_SORT); + + if (board.isNotice() && board.getTag().equals(BoardTag.공지사항.getTag())) { + Page
articles = articleRepository.findByIsNotice(true, pageRequest); + return ArticlesResponse.of(articles.getContent(), board, (long)articles.getTotalPages()); + } + + Page
articles = articleRepository.findByBoardId(boardId, pageRequest); + return ArticlesResponse.of(articles.getContent(), board, (long)articles.getTotalPages()); + } + + public List getHotArticles() { + PageRequest pageRequest = PageRequest.of(0, HOT_ARTICLE_LIMIT, ARTICLES_SORT); + return articleRepository.findAll(pageRequest).stream() + .sorted(Comparator.comparing(Article::getHit).reversed()) + .map(HotArticleItemResponse::from) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java new file mode 100644 index 000000000..d00eadf64 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java @@ -0,0 +1,56 @@ +package in.koreatech.koin.domain.coop.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COOP; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import in.koreatech.koin.domain.coop.dto.DiningImageRequest; +import in.koreatech.koin.domain.coop.dto.SoldOutRequest; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@RequestMapping("/coop") +@Tag(name = "(OWNER) Coop Dining : 영양사 식단", description = "영양사 식단 페이지") +public interface CoopApi { + + @ApiResponses( + + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "특정 코너 품절 요청") + @PatchMapping("/dining/soldout") + ResponseEntity changeSoldOut( + @Auth(permit = {COOP}) Integer userId, + @RequestBody SoldOutRequest soldOutRequest + ); + + @ApiResponses( + + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "이미지 사진 업로드") + @PatchMapping("/dining/image") + ResponseEntity saveDiningImage( + @Auth(permit = {COOP}) Integer userId, + @RequestBody @Valid DiningImageRequest imageRequest + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java new file mode 100644 index 000000000..b673e06c0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.domain.coop.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COOP; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.coop.dto.DiningImageRequest; +import in.koreatech.koin.domain.coop.dto.SoldOutRequest; +import in.koreatech.koin.domain.coop.service.CoopService; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/coop") +@RequiredArgsConstructor +public class CoopController implements CoopApi { + + private final CoopService coopService; + + @PatchMapping("/dining/soldout") + public ResponseEntity changeSoldOut( + @Auth(permit = {COOP}) Integer userId, + @Valid @RequestBody SoldOutRequest soldOutRequest + ) { + coopService.changeSoldOut(soldOutRequest); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/dining/image") + public ResponseEntity saveDiningImage( + @Auth(permit = {COOP}) Integer userId, + @RequestBody @Valid DiningImageRequest imageRequest + ) { + coopService.saveDiningImage(imageRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/dto/DiningImageRequest.java b/src/main/java/in/koreatech/koin/domain/coop/dto/DiningImageRequest.java new file mode 100644 index 000000000..14f80dc6a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/dto/DiningImageRequest.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.coop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(SnakeCaseStrategy.class) +public record DiningImageRequest( + @Schema(description = "메뉴 고유 ID", example = "1", requiredMode = REQUIRED) + @NotNull(message = "메뉴 ID는 필수입니다.") + Integer menuId, + + @Schema(description = "이미지 url", example = "https://api.koreatech.in/image.jpg", requiredMode = REQUIRED) + @NotEmpty(message = "메뉴 이미지는 필수입니다.") + String imageUrl +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/dto/SoldOutRequest.java b/src/main/java/in/koreatech/koin/domain/coop/dto/SoldOutRequest.java new file mode 100644 index 000000000..7ccf47822 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/dto/SoldOutRequest.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.coop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record SoldOutRequest( + @Schema(description = "메뉴 고유 ID", example = "1", requiredMode = REQUIRED) + @NotNull(message = "메뉴 ID는 필수입니다.") + Integer menuId, + + @Schema(description = "품절 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "품절 여부는 필수입니다.") + Boolean soldOut +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/exception/MenuNotFoundException.java b/src/main/java/in/koreatech/koin/domain/coop/exception/MenuNotFoundException.java new file mode 100644 index 000000000..71bd42a5d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/exception/MenuNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.coop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class MenuNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 메뉴입니다."; + + public MenuNotFoundException(String message) { + super(message); + } + + public MenuNotFoundException(String message, String detail) { + super(message, detail); + } + + public static MenuNotFoundException withDetail(String detail) { + return new MenuNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java b/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java new file mode 100644 index 000000000..b460bc551 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.domain.coop.model; + +import static in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType.DINING_SOLD_OUT; +import static in.koreatech.koin.global.fcm.MobileAppPath.HOME; +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.domain.notification.model.NotificationFactory; +import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.global.domain.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional(propagation = Propagation.REQUIRES_NEW) +public class CoopEventListener { + + private final NotificationService notificationService; + private final UserRepository userRepository; + private final NotificationSubscribeRepository notificationSubscribeRepository; + private final NotificationFactory notificationFactory; + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onDiningSoldOutRequest(DiningSoldOutEvent event) { + var notifications = notificationSubscribeRepository.findAllBySubscribeType(DINING_SOLD_OUT).stream() + .map(subscribe -> userRepository.getById(subscribe.getUser().getId())) + .filter(user -> user.getDeviceToken() != null) + .map(user -> notificationFactory.generateSoldOutNotification( + HOME, + event.place(), + user + )).toList(); + notificationService.push(notifications); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/model/DiningSoldOutEvent.java b/src/main/java/in/koreatech/koin/domain/coop/model/DiningSoldOutEvent.java new file mode 100644 index 000000000..b69d2e1e1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/model/DiningSoldOutEvent.java @@ -0,0 +1,6 @@ +package in.koreatech.koin.domain.coop.model; + +public record DiningSoldOutEvent( + String place +) { +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java new file mode 100644 index 000000000..15a201d35 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java @@ -0,0 +1,44 @@ +package in.koreatech.koin.domain.coop.service; + +import java.time.Clock; +import java.time.LocalDateTime; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.coop.dto.DiningImageRequest; +import in.koreatech.koin.domain.coop.dto.SoldOutRequest; +import in.koreatech.koin.domain.coop.model.DiningSoldOutEvent; +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.repository.DiningRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CoopService { + + private final DiningRepository diningRepository; + + private final Clock clock; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public void changeSoldOut(SoldOutRequest soldOutRequest) { + Dining dining = diningRepository.getById(soldOutRequest.menuId()); + + if (Boolean.TRUE.equals(soldOutRequest.soldOut())) { + dining.setSoldOut(LocalDateTime.now(clock)); + } else { + dining.setSoldOut(null); + } + eventPublisher.publishEvent(new DiningSoldOutEvent(dining.getPlace())); + } + + @Transactional + public void saveDiningImage(DiningImageRequest imageRequest) { + Dining dining = diningRepository.getById(imageRequest.menuId()); + dining.setImageUrl(imageRequest.imageUrl()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dept/controller/DeptApi.java b/src/main/java/in/koreatech/koin/domain/dept/controller/DeptApi.java new file mode 100644 index 000000000..7430ad629 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dept/controller/DeptApi.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.domain.dept.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.dept.dto.DeptResponse; +import in.koreatech.koin.domain.dept.dto.DeptsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Dept: 학과", description = "학과 정보를 관리한다") +public interface DeptApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "학과 단건 조회") + @GetMapping("/dept") + ResponseEntity getDept( + @RequestParam(value = "dept_num") String deptNumber + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "학과 목록 조회") + @GetMapping("/depts") + ResponseEntity> getAllDept(); +} diff --git a/src/main/java/in/koreatech/koin/domain/dept/controller/DeptController.java b/src/main/java/in/koreatech/koin/domain/dept/controller/DeptController.java new file mode 100644 index 000000000..83a25254d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dept/controller/DeptController.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.domain.dept.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.dept.dto.DeptResponse; +import in.koreatech.koin.domain.dept.dto.DeptsResponse; +import in.koreatech.koin.domain.dept.service.DeptService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class DeptController implements DeptApi { + + private final DeptService deptService; + + @GetMapping("/dept") + public ResponseEntity getDept( + @RequestParam(value = "dept_num") String deptNumber + ) { + DeptResponse foundDepartment = deptService.getById(deptNumber); + if (foundDepartment == null) { + return ResponseEntity.ok().build(); + } + return ResponseEntity.ok(foundDepartment); + } + + @GetMapping("/depts") + public ResponseEntity> getAllDept() { + List response = deptService.getAll(); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dept/dto/DeptResponse.java b/src/main/java/in/koreatech/koin/domain/dept/dto/DeptResponse.java new file mode 100644 index 000000000..aad97502d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dept/dto/DeptResponse.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.dept.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.dept.model.Dept; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record DeptResponse( + @Schema(description = "학과 번호", example = "36", requiredMode = REQUIRED) + String deptNum, + + @Schema(description = "이름", example = "컴퓨터공학부", requiredMode = REQUIRED) + String name +) { + + public static DeptResponse from(String findNumber, Dept dept) { + return new DeptResponse(findNumber, dept.getName()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dept/dto/DeptsResponse.java b/src/main/java/in/koreatech/koin/domain/dept/dto/DeptsResponse.java new file mode 100644 index 000000000..ba5172106 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dept/dto/DeptsResponse.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.domain.dept.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.dept.model.Dept; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record DeptsResponse( + @Schema(description = "학과 명", example = "컴퓨터공학부", requiredMode = REQUIRED) + String name, + @Schema(description = "커리큘럼 바로가기 링크", example = "https://www.koreatech.ac.kr/menu.es?mid=b10402000000", requiredMode = REQUIRED) + String curriculumLink, + + @Schema(description = "학과 번호들", example = """ + [ "35", "36" ] + """, requiredMode = REQUIRED) + List deptNums +) { + + public static DeptsResponse from(Dept dept) { + return new DeptsResponse( + dept.getName(), + dept.getCurriculumLink(), + dept.getNumbers() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dept/model/Dept.java b/src/main/java/in/koreatech/koin/domain/dept/model/Dept.java new file mode 100644 index 000000000..bd570fc7f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dept/model/Dept.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.domain.dept.model; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import lombok.Getter; + +@Getter +public enum Dept { + ARCHITECTURAL_ENGINEERING( + "건축공학부", List.of("72"), + "https://www.koreatech.ac.kr/menu.es?mid=b30202000000"), + EMPLOYMENT_SERVICE_POLICY_DEPARTMENT( + "고용서비스정책학과", List.of("85"), + "https://www.koreatech.ac.kr/board.es?mid=b80302000000&bid=0150"), + MECHANICAL_ENGINEERING( + "기계공학부", List.of("20"), + "https://www.koreatech.ac.kr/menu.es?mid=a70302000000"), + DESIGN_ENGINEERING( + "디자인공학부", List.of("51"), + "https://www.koreatech.ac.kr/menu.es?mid=b20302000000"), + MECHATRONICS_ENGINEERING( + "메카트로닉스공학부", List.of("40"), + "https://www.koreatech.ac.kr/menu.es?mid=a80302010200"), + INDUSTRIAL_MANAGEMENT( + "산업경영학부", List.of("80"), + "https://www.koreatech.ac.kr/menu.es?mid=b60302010100"), + ELECTRICAL_AND_ELECTRONIC_COMMUNICATION_ENGINEERING( + "전기전자통신공학부", List.of("61"), + "https://www.koreatech.ac.kr/menu.es?mid=a90302010200"), + COMPUTER_SCIENCE( + "컴퓨터공학부", List.of("35", "36"), + "https://www.koreatech.ac.kr/menu.es?mid=b10402000000"), + + // 신설 과는 학과 고유 번호로 N/A를 넣어줌 + CHEMIACL_ENGINEERING( + "화학생명공학부", List.of("N/A"), + "https://www.koreatech.ac.kr/board.es?mid=b50301000000&bid=0135"), + ENERGY_MATERIALS_ENGINEERING( + "에너지신소재공학부", List.of("N/A"), + "https://www.koreatech.ac.kr/board.es?mid=b40301000000&bid=0128"), + + // 없어진 과(고 학번을 위해 유지) + NEW_ENERGY_MATERIALS_CHEMICAL_ENGINEERING( + "에너지신소재화학공학부", List.of("74"), + "https://cms3.koreatech.ac.kr/ace/992/subview.do"); + + private final String name; + private final List numbers; + private final String curriculumLink; + + Dept(String name, List numbers, String curriculumLink) { + this.name = name; + this.numbers = numbers; + this.curriculumLink = curriculumLink; + } + + public static Optional findByNumber(String number) { + for (Dept dept : Dept.values()) { + if (dept.numbers.contains(number)) { + return Optional.of(dept); + } + } + return Optional.empty(); + } + + public static List findAll() { + return Arrays.stream(values()).toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dept/service/DeptService.java b/src/main/java/in/koreatech/koin/domain/dept/service/DeptService.java new file mode 100644 index 000000000..e8572b35b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dept/service/DeptService.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.dept.service; + +import static in.koreatech.koin.domain.dept.model.Dept.NEW_ENERGY_MATERIALS_CHEMICAL_ENGINEERING; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import in.koreatech.koin.domain.dept.dto.DeptResponse; +import in.koreatech.koin.domain.dept.dto.DeptsResponse; +import in.koreatech.koin.domain.dept.model.Dept; + +@Service +public class DeptService { + + public DeptResponse getById(String id) { + Optional dept = Dept.findByNumber(id); + return dept.map(value -> DeptResponse.from(id, value)).orElse(null); + } + + public List getAll() { + return Dept.findAll() + .stream() + .filter(dept -> !NEW_ENERGY_MATERIALS_CHEMICAL_ENGINEERING.equals(dept)) + .map(DeptsResponse::from) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningApi.java b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningApi.java new file mode 100644 index 000000000..f4294071b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningApi.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.domain.dining.controller; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.dining.dto.DiningResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Dining: 식단", description = "식단 정보를 관리한다") +public interface DiningApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "식단 목록 조회") + @GetMapping("/dinings") + ResponseEntity> getDinings( + @DateTimeFormat(pattern = "yyMMdd") + @Parameter(description = "조회 날짜(yyMMdd)") @RequestParam(required = false) LocalDate date + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningController.java b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningController.java new file mode 100644 index 000000000..12448c6e1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningController.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.domain.dining.controller; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.dining.dto.DiningResponse; +import in.koreatech.koin.domain.dining.service.DiningService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class DiningController implements DiningApi { + + private final DiningService diningService; + + @GetMapping("/dinings") + public ResponseEntity> getDinings( + @DateTimeFormat(pattern = "yyMMdd") + @RequestParam(required = false) LocalDate date + ) { + List responses = diningService.getDinings(date); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java new file mode 100644 index 000000000..81fbc3f24 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java @@ -0,0 +1,84 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.dining.model.Dining; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record DiningResponse( + + @Schema(description = "메뉴 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "식단 제공 날짜", example = "2024-03-11", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate date, + + @Schema(description = "식사 시간", example = "LUNCH", requiredMode = REQUIRED) + String type, + + @Schema(description = "식단 제공 장소", example = "A코스", requiredMode = REQUIRED) + String place, + + @Schema(description = "카드 가격", example = "5000", requiredMode = NOT_REQUIRED) + Integer priceCard, + + @Schema(description = "현금 가격", example = "5000", requiredMode = NOT_REQUIRED) + Integer priceCash, + + @Schema(description = "칼로리", example = "790", requiredMode = NOT_REQUIRED) + Integer kcal, + + @Schema(description = "식단", example = """ + ["병아리콩밥", "(탕)소고기육개장", "땡초부추전", "고구마순들깨볶음", "총각김치", "생야채샐러드&D", "누룽지탕"] + """, requiredMode = REQUIRED) + List menu, + + @Schema(description = "이미지 URL", example = "https://stage.koreatech.in/image.jpg", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(description = "생성 일자", example = "2024-03-15 14:02:48", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @Schema(description = "최신화 일자", example = "2024-03-15 14:02:48", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime updatedAt, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "품절 시각", example = "2024-04-04 23:01:52", requiredMode = NOT_REQUIRED) + LocalDateTime soldoutAt, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "메뉴 변경 시각", example = "2024-04-04 23:01:52", requiredMode = NOT_REQUIRED) + LocalDateTime changedAt +) { + + public static DiningResponse from(Dining dining) { + return new DiningResponse( + dining.getId(), + dining.getDate(), + dining.getType().name(), + dining.getPlace(), + dining.getPriceCard() != null ? dining.getPriceCard() : 0, + dining.getPriceCash() != null ? dining.getPriceCash() : 0, + dining.getKcal() != null ? dining.getKcal() : 0, + dining.getMenu(), + dining.getImageUrl(), + dining.getCreatedAt(), + dining.getUpdatedAt(), + dining.getSoldOut(), + dining.getIsChanged() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/exception/DiningTypeNotFoundException.java b/src/main/java/in/koreatech/koin/domain/dining/exception/DiningTypeNotFoundException.java new file mode 100644 index 000000000..802c48529 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/exception/DiningTypeNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.dining.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class DiningTypeNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "식사 타입이 존재하지 않습니다."; + + public DiningTypeNotFoundException(String message) { + super(message); + } + + public DiningTypeNotFoundException(String message, String detail) { + super(message, detail); + } + + public static DiningTypeNotFoundException withDetail(String detail) { + return new DiningTypeNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java b/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java new file mode 100644 index 000000000..b1d4006a5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java @@ -0,0 +1,120 @@ +package in.koreatech.koin.domain.dining.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Stream; + +import in.koreatech.koin.global.domain.BaseEntity; +import in.koreatech.koin.global.exception.KoinIllegalStateException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "dining_menus", uniqueConstraints = { + @UniqueConstraint( + name = "ux_date_type_place", + columnNames = {"date", "type", "place"} + ) +}) +public class Dining extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @NotNull + @Column(name = "date", nullable = false) + private LocalDate date; + + @NotNull + @Enumerated(value = STRING) + @Column(name = "type", nullable = false) + private DiningType type; + + @NotNull + @Column(name = "place", nullable = false) + private String place; + + @Column(name = "price_card") + private Integer priceCard; + + @Column(name = "price_cash") + private Integer priceCash; + + @Column(name = "kcal") + private Integer kcal; + + @NotNull + @Column(name = "menu", nullable = false) + private String menu; + + @Column(name = "image_url") + private String imageUrl; + + @Column(name = "sold_out", columnDefinition = "DATETIME") + private LocalDateTime soldOut; + + @Column(name = "is_changed", columnDefinition = "DATETIME") + private LocalDateTime isChanged; + + @Builder + private Dining( + LocalDate date, + DiningType type, + String place, + Integer priceCard, + Integer priceCash, + Integer kcal, + String menu, + String imageUrl, + LocalDateTime soldOut, + LocalDateTime isChanged + ) { + this.date = date; + this.type = type; + this.place = place; + this.priceCard = priceCard; + this.priceCash = priceCash; + this.kcal = kcal; + this.menu = menu; + this.imageUrl = imageUrl; + this.soldOut = soldOut; + this.isChanged = isChanged; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public void setSoldOut(LocalDateTime soldout) { + this.soldOut = soldout; + } + + /** + * DB에 "[메뉴, 메뉴, ...]" 형태로 저장되어 List로 파싱하여 반환 + */ + public List getMenu() { + if (menu == null || menu.isBlank()) { + throw new KoinIllegalStateException("메뉴가 잘못된 형태로 저장되어있습니다.", menu); + } + return Stream.of(menu.substring(1, menu.length() - 1).split(",")) + .map(str -> str.strip().replace("\"", "")) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningType.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningType.java new file mode 100644 index 000000000..6696fde6f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningType.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.domain.dining.model; + +import java.util.Arrays; + +import in.koreatech.koin.domain.dining.exception.DiningTypeNotFoundException; +import lombok.Getter; + +@Getter +public enum DiningType { + BREAKFAST("아침"), + LUNCH("점심"), + DINNER("저녁"), + ; + + private final String label; + + DiningType(String label) { + this.label = label; + } + + public static DiningType from(String diningType) { + return Arrays.stream(values()) + .filter(it -> + it.label.equalsIgnoreCase(diningType) || + it.name().equalsIgnoreCase(diningType) + ) + .findAny() + .orElseThrow(() -> DiningTypeNotFoundException.withDetail(diningType)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningRepository.java new file mode 100644 index 000000000..e168b5b5a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningRepository.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.dining.repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.coop.exception.MenuNotFoundException; +import in.koreatech.koin.domain.dining.model.Dining; + +public interface DiningRepository extends Repository { + + Dining save(Dining dining); + + Optional findById(Integer id); + + List findAllByDate(LocalDate date); + + default Dining getById(Integer id) { + return findById(id) + .orElseThrow(() -> MenuNotFoundException.withDetail("menuId: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/service/DiningService.java b/src/main/java/in/koreatech/koin/domain/dining/service/DiningService.java new file mode 100644 index 000000000..916bb8207 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/service/DiningService.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.domain.dining.service; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.dining.dto.DiningResponse; +import in.koreatech.koin.domain.dining.repository.DiningRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DiningService { + + private final DiningRepository diningRepository; + + private final Clock clock; + + public List getDinings(LocalDate date) { + if (date == null) { + date = LocalDate.now(clock); + } + return diningRepository.findAllByDate(date) + .stream() + .map(DiningResponse::from) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/config/KakaoRequest.java b/src/main/java/in/koreatech/koin/domain/kakao/config/KakaoRequest.java new file mode 100644 index 000000000..75c4e9480 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/config/KakaoRequest.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.kakao.config; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import in.koreatech.koin.domain.kakao.model.KakaoRequestType; +import io.swagger.v3.oas.annotations.Hidden; + +@Hidden // Swagger 문서에 표시하지 않음 +@Target(PARAMETER) +@Retention(RUNTIME) +public @interface KakaoRequest { + + KakaoRequestType type(); +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/config/KakaoRequestBodyAdvice.java b/src/main/java/in/koreatech/koin/domain/kakao/config/KakaoRequestBodyAdvice.java new file mode 100644 index 000000000..6aef6eaad --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/config/KakaoRequestBodyAdvice.java @@ -0,0 +1,84 @@ +package in.koreatech.koin.domain.kakao.config; + +import static java.util.Objects.requireNonNull; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice; + +@RestControllerAdvice +public class KakaoRequestBodyAdvice implements RequestBodyAdvice { + + private String requestBody; + + @Override + public boolean supports( + MethodParameter methodParameter, + Type targetType, + Class> converterType + ) { + return methodParameter.hasParameterAnnotation(KakaoRequest.class); + } + + @Override + public HttpInputMessage beforeBodyRead( + HttpInputMessage inputMessage, + MethodParameter parameter, + Type targetType, + Class> converterType + ) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(inputMessage.getBody(), StandardCharsets.UTF_8) + )) { + requestBody = reader.lines().collect(Collectors.joining("\n")); + } catch (Exception ignored) { + } + ByteArrayInputStream newInputStream = new ByteArrayInputStream(requestBody.getBytes(StandardCharsets.UTF_8)); + return new HttpInputMessage() { + @Override + public InputStream getBody() { + return newInputStream; + } + + @Override + public HttpHeaders getHeaders() { + return inputMessage.getHeaders(); + } + }; + } + + @Override + public Object afterBodyRead( + Object body, + HttpInputMessage inputMessage, + MethodParameter parameter, + Type targetType, + Class> converterType + ) { + KakaoRequest authAt = parameter.getParameterAnnotation(KakaoRequest.class); + requireNonNull(authAt, "KakaoRequest annotation cannot be null"); + return authAt.type().parse(requestBody); + } + + @Override + public Object handleEmptyBody( + Object body, + HttpInputMessage inputMessage, + MethodParameter parameter, + Type targetType, + Class> converterType + ) { + return body; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/controller/KakaoBotController.java b/src/main/java/in/koreatech/koin/domain/kakao/controller/KakaoBotController.java new file mode 100644 index 000000000..9bfb001fb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/controller/KakaoBotController.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.domain.kakao.controller; + +import static in.koreatech.koin.domain.kakao.model.KakaoRequestType.BUS_TIME; +import static in.koreatech.koin.domain.kakao.model.KakaoRequestType.DINING; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import org.springframework.http.ResponseEntity; +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 in.koreatech.koin.domain.kakao.config.KakaoRequest; +import in.koreatech.koin.domain.kakao.dto.KakaoBusRequest; +import in.koreatech.koin.domain.kakao.dto.KakaoDiningRequest; +import in.koreatech.koin.domain.kakao.service.KakaoBotService; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; + +@Hidden +@RestController +@RequiredArgsConstructor +@RequestMapping("/koinbot") +public class KakaoBotController { + + private final KakaoBotService kakaoBotService; + + @PostMapping(value = "/dinings", produces = APPLICATION_JSON_VALUE) + public ResponseEntity requestDinings( + @RequestBody @KakaoRequest(type = DINING) KakaoDiningRequest diningRequest + ) { + var result = kakaoBotService.getDiningMenus(diningRequest); + return ResponseEntity.ok(result); + } + + @PostMapping(value = "/buses", produces = APPLICATION_JSON_VALUE) + public ResponseEntity requestBusTimes( + @RequestBody @KakaoRequest(type = BUS_TIME) KakaoBusRequest request + ) { + var result = kakaoBotService.getBusRemainTime(request); + return ResponseEntity.ok(result); + } + + @PostMapping(value = "/buses/request", produces = APPLICATION_JSON_VALUE) + public ResponseEntity requestBusRoutes() { + var result = kakaoBotService.getBusRoutes(); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/dto/KakaoBusRequest.java b/src/main/java/in/koreatech/koin/domain/kakao/dto/KakaoBusRequest.java new file mode 100644 index 000000000..52543610e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/dto/KakaoBusRequest.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.domain.kakao.dto; + +import io.swagger.v3.oas.annotations.Hidden; + +@Hidden +public record KakaoBusRequest( + String depart, + String arrival +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/dto/KakaoDiningRequest.java b/src/main/java/in/koreatech/koin/domain/kakao/dto/KakaoDiningRequest.java new file mode 100644 index 000000000..b0a91f79d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/dto/KakaoDiningRequest.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.domain.kakao.dto; + +import in.koreatech.koin.domain.dining.model.DiningType; +import io.swagger.v3.oas.annotations.Hidden; + +@Hidden +public record KakaoDiningRequest( + DiningType diningType +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/dto/KakaoSkillResponse.java b/src/main/java/in/koreatech/koin/domain/kakao/dto/KakaoSkillResponse.java new file mode 100644 index 000000000..6e5b73be3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/dto/KakaoSkillResponse.java @@ -0,0 +1,85 @@ +package in.koreatech.koin.domain.kakao.dto; + +import static lombok.AccessLevel.PRIVATE; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import in.koreatech.koin.domain.kakao.exception.KakaoSkillFailedException; +import lombok.NoArgsConstructor; + +/** + * + * KAKAO 비즈니스 기술문서 - Bot-Skill 서버 연동 API + * + */ +@NoArgsConstructor(access = PRIVATE) +public class KakaoSkillResponse { + + private static final int OUTPUTS_LIMIT = 3; + private static final int QUICK_REPLIES_LIMIT = 10; + private static final int MAX_QUICK_REPLIES_LABEL_SIZE = 8; + + private static final String SIMPLE_TEXT = "simpleText"; + public static final String QUICK_ACTION_MESSAGE = "message"; + + public static SkillResponseBuilder builder() { + return new SkillResponseBuilder(); + } + + public static class SkillResponseBuilder { + + private final JsonArray outputs = new JsonArray(); + private final JsonObject template = new JsonObject(); + private final JsonArray quickReplies = new JsonArray(); + private final JsonObject skillPayload = new JsonObject(); + + public SkillResponseBuilder() { + skillPayload.addProperty("version", "2.0"); + skillPayload.add("template", template); + template.add("outputs", outputs); + template.add("quickReplies", quickReplies); + } + + public SkillResponseBuilder simpleText(String text) { + + if (outputs.size() > OUTPUTS_LIMIT) { + throw new KakaoSkillFailedException("outputs의 제한은 1개 이상 3개 이하입니다."); + } + JsonObject field = new JsonObject(); + JsonObject type = new JsonObject(); + field.addProperty("text", text); + type.add(SIMPLE_TEXT, field); + outputs.add(type); + return this; + } + + public SkillResponseBuilder quickReply(String label, String action, String messageText) { + if (quickReplies.size() > QUICK_REPLIES_LIMIT) { + throw new KakaoSkillFailedException("quickReplies의 제한은 10개 이하입니다."); + } + if (!QUICK_ACTION_MESSAGE.equals(action)) { + throw new KakaoSkillFailedException("quickReplies의 action이 올바르게 설정되지 않았습니다."); + } + + if (label.length() > MAX_QUICK_REPLIES_LABEL_SIZE) { + throw new KakaoSkillFailedException("quickReplies에서 label은 최대 8자 제한입니다."); + } + + JsonObject field = new JsonObject(); + field.addProperty("action", action); + field.addProperty("label", label); + field.addProperty("messageText", messageText); + + quickReplies.add(field); + return this; + } + + public String build() { + if (outputs.isEmpty()) { + throw new KakaoSkillFailedException("outputs는 필수 항목입니다."); + } + return skillPayload.toString(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/exception/KakaoApiException.java b/src/main/java/in/koreatech/koin/domain/kakao/exception/KakaoApiException.java new file mode 100644 index 000000000..db9a0f10d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/exception/KakaoApiException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.kakao.exception; + +import in.koreatech.koin.global.exception.ExternalServiceException; + +public class KakaoApiException extends ExternalServiceException { + + private static final String DEFAULT_MESSAGE = "카카오 API 호출 과정에서 문제가 발생했습니다."; + + public KakaoApiException(String message) { + super(message); + } + + public KakaoApiException(String message, String detail) { + super(message, detail); + } + + public static KakaoApiException withDetail(String detail) { + return new KakaoApiException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/exception/KakaoSkillFailedException.java b/src/main/java/in/koreatech/koin/domain/kakao/exception/KakaoSkillFailedException.java new file mode 100644 index 000000000..649683337 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/exception/KakaoSkillFailedException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.kakao.exception; + +import in.koreatech.koin.global.exception.KoinIllegalStateException; + +public class KakaoSkillFailedException extends KoinIllegalStateException { + + private static final String DEFAULT_MESSAGE = "카카오 챗봇 응답 생성과정에서 문제가 생겼습니다."; + + public KakaoSkillFailedException(String message) { + super(message); + } + + public KakaoSkillFailedException(String message, String detail) { + super(message, detail); + } + + public static KakaoSkillFailedException withDetail(String detail) { + return new KakaoSkillFailedException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoBusRequestParser.java b/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoBusRequestParser.java new file mode 100644 index 000000000..e943baafd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoBusRequestParser.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.kakao.model; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import in.koreatech.koin.domain.kakao.dto.KakaoBusRequest; +import in.koreatech.koin.domain.kakao.exception.KakaoApiException; + +public class KakaoBusRequestParser implements KakaoSkillRequestParser { + + @Override + public KakaoBusRequest parse(String request) throws KakaoApiException { + try { + JsonElement jsonElement = JsonParser.parseString(request); + JsonElement action = jsonElement.getAsJsonObject().get("action"); + JsonElement params = action.getAsJsonObject().get("params"); + String depart = params.getAsJsonObject().get("depart").getAsString(); + String arrival = params.getAsJsonObject().get("arrival").getAsString(); + return new KakaoBusRequest(depart, arrival); + } catch (Exception e) { + throw new KakaoApiException("잘못된 API 요청 형식입니다.", request); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoDiningRequestParser.java b/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoDiningRequestParser.java new file mode 100644 index 000000000..9c8d0c1a3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoDiningRequestParser.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.domain.kakao.model; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import in.koreatech.koin.domain.dining.exception.DiningTypeNotFoundException; +import in.koreatech.koin.domain.dining.model.DiningType; +import in.koreatech.koin.domain.kakao.dto.KakaoDiningRequest; +import in.koreatech.koin.domain.kakao.exception.KakaoApiException; + +public class KakaoDiningRequestParser implements KakaoSkillRequestParser { + + @Override + public KakaoDiningRequest parse(String request) { + try { + JsonElement jsonElement = JsonParser.parseString(request); + JsonElement action = jsonElement.getAsJsonObject().get("action"); + JsonElement params = action.getAsJsonObject().get("params"); + String diningTime = params.getAsJsonObject().get("dining_time").getAsString(); + return new KakaoDiningRequest(DiningType.from(diningTime)); + } catch (DiningTypeNotFoundException e) { + throw e; + } catch (Exception e) { + throw new KakaoApiException("잘못된 API 요청 형식입니다.", request); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoRequestType.java b/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoRequestType.java new file mode 100644 index 000000000..3a7925d77 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoRequestType.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.domain.kakao.model; + +public enum KakaoRequestType { + DINING(new KakaoDiningRequestParser()), + BUS_TIME(new KakaoBusRequestParser()), + ; + + private final KakaoSkillRequestParser parser; + + KakaoRequestType(KakaoSkillRequestParser parser) { + this.parser = parser; + } + + public R parse(String request) { + return (R)parser.parse(request); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoSkillRequestParser.java b/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoSkillRequestParser.java new file mode 100644 index 000000000..8bade7c4f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/model/KakaoSkillRequestParser.java @@ -0,0 +1,8 @@ +package in.koreatech.koin.domain.kakao.model; + +import in.koreatech.koin.domain.kakao.exception.KakaoApiException; + +public interface KakaoSkillRequestParser { + + R parse(T request) throws KakaoApiException; +} diff --git a/src/main/java/in/koreatech/koin/domain/kakao/service/KakaoBotService.java b/src/main/java/in/koreatech/koin/domain/kakao/service/KakaoBotService.java new file mode 100644 index 000000000..502d9777b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/kakao/service/KakaoBotService.java @@ -0,0 +1,130 @@ +package in.koreatech.koin.domain.kakao.service; + +import static in.koreatech.koin.domain.kakao.dto.KakaoSkillResponse.QUICK_ACTION_MESSAGE; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.StringJoiner; + +import org.springframework.stereotype.Service; + +import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; +import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse.InnerBusResponse; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import in.koreatech.koin.domain.bus.model.enums.BusType; +import in.koreatech.koin.domain.bus.service.BusService; +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.model.DiningType; +import in.koreatech.koin.domain.dining.repository.DiningRepository; +import in.koreatech.koin.domain.kakao.dto.KakaoBusRequest; +import in.koreatech.koin.domain.kakao.dto.KakaoDiningRequest; +import in.koreatech.koin.domain.kakao.dto.KakaoSkillResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class KakaoBotService { + + private final Clock clock; + private final DiningRepository diningRepository; + private final BusService busService; + + public String getDiningMenus(KakaoDiningRequest request) { + DiningType diningType = request.diningType(); + StringJoiner result = new StringJoiner(System.lineSeparator()); + var now = LocalDateTime.now(clock); + List dinings = diningRepository.findAllByDate(now.toLocalDate()).stream() + .filter(it -> it.getType().equals(diningType)) + .toList(); + + if (dinings.isEmpty()) { + return String.format("금일 %s식사는 운영되지 않습니다.", diningType.getLabel()); + } + for (Dining dining : dinings) { + result.add(String.format("# %s", dining.getPlace())); + for (String menu : dining.getMenu()) { + result.add(menu); + } + result.add(String.format("%dkcal", dining.getKcal())); + result.add(String.format("현금 %d원", dining.getPriceCash())); + result.add(String.format("페이코 %d원", dining.getPriceCard())); + result.add("────────────"); + } + return KakaoSkillResponse.builder() + .simpleText(result.toString()) + .build(); + } + + public String getBusRemainTime(KakaoBusRequest request) { + StringJoiner resultNow = new StringJoiner(System.lineSeparator()); + resultNow.add("[바로 도착]"); + StringJoiner resultNext = new StringJoiner(System.lineSeparator()); + resultNext.add("[다음 도착]"); + + BusStation depart = BusStation.from(request.depart()); + BusStation arrival = BusStation.from(request.arrival()); + + StringJoiner nowBuses = new StringJoiner(System.lineSeparator()); + StringJoiner nextBuses = new StringJoiner(System.lineSeparator()); + for (BusType type : BusType.values()) { + try { + BusRemainTimeResponse remainTime = busService.getBusRemainTime( + type, + depart, + arrival + ); + String nowRemain = getRemainTime(type, remainTime.nowBus()); + if (nowRemain != null) { + nowBuses.add(nowRemain); + } + String nextRemain = getRemainTime(type, remainTime.nextBus()); + if (nextRemain != null) { + nextBuses.add(nextRemain); + } + } catch (Exception ignore) { + } + } + + if (nowBuses.length() == 0) { + resultNow.add("버스 운행정보없음"); + } + if (nextBuses.length() == 0) { + resultNext.add("버스 운행정보없음"); + } + resultNow.add(nowBuses.toString()); + resultNext.add(nextBuses.toString()); + + return KakaoSkillResponse.builder() + .simpleText(resultNow.toString()) + .simpleText(resultNext.toString()) + .build(); + } + + private String getRemainTime( + BusType type, + InnerBusResponse response + ) { + if (response != null) { + return String.format("%s, %d시간 %d분 %d초 남음", + type.getLabel(), + response.remainTime() / 3600, + response.remainTime() % 3600 / 60, + response.remainTime() % 60 + ); + } + return null; + } + + public String getBusRoutes() { + return KakaoSkillResponse.builder() + .simpleText("선택하세요!") + .quickReply("한기대→터미널", QUICK_ACTION_MESSAGE, "한기대→터미널") + .quickReply("한기대→천안역", QUICK_ACTION_MESSAGE, "한기대→천안역") + .quickReply("터미널→한기대", QUICK_ACTION_MESSAGE, "터미널→한기대") + .quickReply("터미널→천안역", QUICK_ACTION_MESSAGE, "터미널→천안역") + .quickReply("천안역→한기대", QUICK_ACTION_MESSAGE, "천안역→한기대") + .quickReply("천안역→터미널", QUICK_ACTION_MESSAGE, "천안역→터미널") + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/land/controller/LandApi.java b/src/main/java/in/koreatech/koin/domain/land/controller/LandApi.java new file mode 100644 index 000000000..e4c743165 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/controller/LandApi.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.domain.land.controller; + +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import in.koreatech.koin.domain.land.dto.LandResponse; +import in.koreatech.koin.domain.land.dto.LandsGroupResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Land: 복덕방", description = "복덕방 정보를 관리한다") +public interface LandApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 목록 조회") + @GetMapping("/lands") + ResponseEntity getLands(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 단건 조회") + @GetMapping("/lands/{id}") + ResponseEntity getLand( + @Parameter(in = PATH) @PathVariable Integer id + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/land/controller/LandController.java b/src/main/java/in/koreatech/koin/domain/land/controller/LandController.java new file mode 100644 index 000000000..7fa1e828b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/controller/LandController.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.domain.land.controller; + +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.RestController; + +import in.koreatech.koin.domain.land.dto.LandResponse; +import in.koreatech.koin.domain.land.dto.LandsGroupResponse; +import in.koreatech.koin.domain.land.service.LandService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class LandController implements LandApi { + + private final LandService landService; + + @GetMapping("/lands") + public ResponseEntity getLands() { + LandsGroupResponse responses = landService.getLands(); + return ResponseEntity.ok(responses); + } + + @GetMapping("/lands/{id}") + public ResponseEntity getLand( + @PathVariable Integer id + ) { + LandResponse response = landService.getLand(id); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java b/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java new file mode 100644 index 000000000..e296cbdcf --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java @@ -0,0 +1,171 @@ +package in.koreatech.koin.domain.land.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.land.model.Land; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record LandResponse( + @Schema(description = "전자 도어 락 옵션", example = "true", requiredMode = REQUIRED) + boolean optElectronicDoorLocks, + + @Schema(description = "TV 옵션", example = "true", requiredMode = REQUIRED) + boolean optTv, + + @Schema(description = "월세 범위", example = "220~250만원(6개월)", requiredMode = NOT_REQUIRED) + String monthlyFee, + + @Schema(description = "엘리베이터 옵션", example = "true", requiredMode = REQUIRED) + boolean optElevator, + + @Schema(description = "정수기 옵션", example = "true", requiredMode = REQUIRED) + boolean optWaterPurifier, + + @Schema(description = "세탁기 옵션", example = "true", requiredMode = REQUIRED) + boolean optWasher, + + @Schema(description = "위도 좌표", example = "36.769062", requiredMode = NOT_REQUIRED) + Double latitude, + + @Schema(description = "전세 가격", example = "4500", requiredMode = NOT_REQUIRED) + String charterFee, + + @Schema(description = "베란다 옵션", example = "true", requiredMode = REQUIRED) + boolean optVeranda, + + @Schema(description = "생성 일시", example = "2020-08-19 13:44:11", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @Schema(description = "설명", example = "한기대 정문 근처 원룸", requiredMode = NOT_REQUIRED) + String description, + + @Schema(description = "이미지 URL 목록", example = """ + ["https://image.com", "https://image2.com"] + """, requiredMode = REQUIRED) + List imageUrls, + + @Schema(description = "가스 레인지 옵션", example = "false", requiredMode = REQUIRED) + boolean optGasRange, + + @Schema(description = "인덕션 옵션", example = "true", requiredMode = REQUIRED) + boolean optInduction, + + @Schema(description = "내부 이름", example = "행운빌(투베이)", requiredMode = NOT_REQUIRED) + String internalName, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + boolean isDeleted, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "최근 업데이트 일시", example = "2021-05-19 23:07:13", requiredMode = REQUIRED) + LocalDateTime updatedAt, + + @Schema(description = "비데 옵션", example = "false", requiredMode = REQUIRED) + boolean optBidet, + + @Schema(description = "신발장 옵션", example = "true", requiredMode = REQUIRED) + boolean optShoeCloset, + + @Schema(description = "냉장고 옵션", example = "true", requiredMode = REQUIRED) + boolean optRefrigerator, + + @Schema(description = "고유 식별자", example = "68", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "층 수", example = "null", requiredMode = NOT_REQUIRED) + Integer floor, + + @Schema(description = "인당 관리비", example = "21(1인 기준)", requiredMode = NOT_REQUIRED) + String managementFee, + + @Schema(description = "책상 옵션", example = "true", requiredMode = REQUIRED) + boolean optDesk, + + @Schema(description = "옷장 옵션", example = "true", requiredMode = REQUIRED) + boolean optCloset, + + @Schema(description = "경도 좌표", example = "127.28265", requiredMode = NOT_REQUIRED) + Double longitude, + + @Schema(description = "주소", example = "충남 천안시 동남구 병천면 가전6길 17-1", requiredMode = NOT_REQUIRED) + String address, + + @Schema(description = "침대 옵션", example = "true", requiredMode = REQUIRED) + boolean optBed, + + @Schema(description = "크기(제곱 미터)", example = "9", requiredMode = NOT_REQUIRED) + String size, + + @Schema(description = "전화번호", example = "010-3257-5598", requiredMode = NOT_REQUIRED) + String phone, + + @Schema(description = "에어컨 옵션", example = "true", requiredMode = REQUIRED) + boolean optAirConditioner, + + @Schema(description = "부동산 이름", example = "행운빌 (투베이)", requiredMode = NOT_REQUIRED) + String name, + + @Schema(description = "보증금 금액", example = "50", requiredMode = NOT_REQUIRED) + String deposit, + + @Schema(description = "전자레인지 옵션", example = "true", requiredMode = REQUIRED) + boolean optMicrowave, + + @Schema(description = "부동산 링크", example = "%ED%96%89%EC%9A%B4%EB%B9%8C%28%ED%88%AC%EB%B2%A0%EC%9D%B4%29", requiredMode = NOT_REQUIRED) + String permalink, + + @Schema(description = "방 유형", example = "투베이", requiredMode = NOT_REQUIRED) + String roomType +) { + + public static LandResponse of(Land land, List imageUrls, String permalink) { + return new LandResponse( + land.isOptElectronicDoorLocks(), + land.isOptTv(), + land.getMonthlyFee(), + land.isOptElevator(), + land.isOptWaterPurifier(), + land.isOptWasher(), + land.getLatitude(), + land.getCharterFee(), + land.isOptVeranda(), + land.getCreatedAt(), + land.getDescription(), + imageUrls, + land.isOptGasRange(), + land.isOptInduction(), + land.getInternalName(), + land.isDeleted(), + land.getUpdatedAt(), + land.isOptBidet(), + land.isOptShoeCloset(), + land.isOptRefrigerator(), + land.getId(), + land.getFloor(), + land.getManagementFee(), + land.isOptDesk(), + land.isOptCloset(), + land.getLongitude(), + land.getAddress(), + land.isOptBed(), + land.getSize(), + land.getPhone(), + land.isOptAirConditioner(), + land.getName(), + land.getDeposit(), + land.isOptMicrowave(), + permalink, + land.getRoomType() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/land/dto/LandsGroupResponse.java b/src/main/java/in/koreatech/koin/domain/land/dto/LandsGroupResponse.java new file mode 100644 index 000000000..6845205ce --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/dto/LandsGroupResponse.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.domain.land.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record LandsGroupResponse( + @JsonProperty("lands") + List lands +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/land/dto/LandsResponse.java b/src/main/java/in/koreatech/koin/domain/land/dto/LandsResponse.java new file mode 100644 index 000000000..9d85ac808 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/dto/LandsResponse.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.domain.land.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.land.model.Land; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record LandsResponse( + @Schema(description = "상세 이름", example = "럭키빌(투베이)", requiredMode = NOT_REQUIRED) + String internalName, + + @Schema(description = "월세", example = "220~250만원(6개월)", requiredMode = NOT_REQUIRED) + String monthlyFee, + + @Schema(description = "위도", example = "36.769204", requiredMode = NOT_REQUIRED) + Double latitude, + + @Schema(description = "보증금", example = "4500", requiredMode = NOT_REQUIRED) + String charterFee, + + @Schema(description = "이름", example = "럭키빌(투베이)", requiredMode = NOT_REQUIRED) + String name, + + @Schema(description = "복덕방 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "경도", example = "127.282554", requiredMode = NOT_REQUIRED) + Double longitude, + + @Schema(description = "방 종류", example = "투베이", requiredMode = NOT_REQUIRED) + String roomType +) { + + public static LandsResponse from(Land land) { + return new LandsResponse( + land.getInternalName(), + land.getMonthlyFee(), + land.getLatitude(), + land.getCharterFee(), + land.getName(), + land.getId(), + land.getLongitude(), + land.getRoomType() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/land/exception/LandNotFoundException.java b/src/main/java/in/koreatech/koin/domain/land/exception/LandNotFoundException.java new file mode 100644 index 000000000..6a3818fef --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/exception/LandNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.land.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class LandNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "복덕방이 존재하지 않습니다."; + + public LandNotFoundException(String message) { + super(message); + } + + public LandNotFoundException(String message, String detail) { + super(message, detail); + } + + public static LandNotFoundException withDetail(String detail) { + return new LandNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/land/model/Land.java b/src/main/java/in/koreatech/koin/domain/land/model/Land.java new file mode 100644 index 000000000..cdc80a868 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/model/Land.java @@ -0,0 +1,200 @@ +package in.koreatech.koin.domain.land.model; + +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "lands") +@NoArgsConstructor(access = PROTECTED) +public class Land extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Size(max = 255) + @NotNull + @Column(name = "name", nullable = false, unique = true) + private String name; + + @Size(max = 50) + @NotNull + @Column(name = "internal_name", nullable = false, length = 50) + private String internalName; + + @Column(name = "size", length = 20) + private String size; + + @Size(max = 20) + @Column(name = "room_type", length = 20) + private String roomType; + + @Column(name = "latitude", length = 20) + private String latitude; + + @Column(name = "longitude", length = 20) + private String longitude; + + @Size(max = 20) + @Column(name = "phone", length = 20) + private String phone; + + @Column(name = "image_urls") + private String imageUrls; + + @Column(name = "address") + private String address; + + @Column(name = "description") + private String description; + + @Column(name = "floor") + private Integer floor; + + @Size(max = 255) + @Column(name = "deposit") + private String deposit; + + @Size(max = 255) + @Column(name = "monthly_fee") + private String monthlyFee; + + @Size(max = 20) + @Column(name = "charter_fee", length = 20) + private String charterFee; + + @Size(max = 255) + @Column(name = "management_fee") + private String managementFee; + + @NotNull + @Column(name = "opt_refrigerator", nullable = false) + private boolean optRefrigerator = false; + + @NotNull + @Column(name = "opt_closet", nullable = false) + private boolean optCloset = false; + + @NotNull + @Column(name = "opt_tv", nullable = false) + private boolean optTv = false; + + @NotNull + @Column(name = "opt_microwave", nullable = false) + private boolean optMicrowave = false; + + @NotNull + @Column(name = "opt_gas_range", nullable = false) + private boolean optGasRange = false; + + @NotNull + @Column(name = "opt_induction", nullable = false) + private boolean optInduction = false; + + @NotNull + @Column(name = "opt_water_purifier", nullable = false) + private boolean optWaterPurifier = false; + + @NotNull + @Column(name = "opt_air_conditioner", nullable = false) + private boolean optAirConditioner = false; + + @NotNull + @Column(name = "opt_washer", nullable = false) + private boolean optWasher = false; + + @NotNull + @Column(name = "opt_bed", nullable = false) + private boolean optBed = false; + + @NotNull + @Column(name = "opt_desk", nullable = false) + private boolean optDesk = false; + + @NotNull + @Column(name = "opt_shoe_closet", nullable = false) + private boolean optShoeCloset = false; + + @NotNull + @Column(name = "opt_electronic_door_locks", nullable = false) + private boolean optElectronicDoorLocks = false; + + @NotNull + @Column(name = "opt_bidet", nullable = false) + private boolean optBidet = false; + + @NotNull + @Column(name = "opt_veranda", nullable = false) + private boolean optVeranda = false; + + @NotNull + @Column(name = "opt_elevator", nullable = false) + private boolean optElevator = false; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Builder + private Land( + String internalName, + String name, + String size, + String roomType, + String latitude, + String longitude, + String phone, + String imageUrls, + String address, + String description, + Integer floor, + String deposit, + String monthlyFee, + String charterFee, + String managementFee + ) { + this.internalName = internalName; + this.name = name; + this.size = size; + this.roomType = roomType; + this.latitude = latitude; + this.longitude = longitude; + this.phone = phone; + this.imageUrls = imageUrls; + this.address = address; + this.description = description; + this.floor = floor; + this.deposit = deposit; + this.monthlyFee = monthlyFee; + this.charterFee = charterFee; + this.managementFee = managementFee; + } + + public Double getLatitude() { + if (this.latitude == null) { + return null; + } + return Double.parseDouble(latitude); + } + + public Double getLongitude() { + if (this.longitude == null) { + return null; + } + return Double.parseDouble(longitude); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/land/repository/LandRepository.java b/src/main/java/in/koreatech/koin/domain/land/repository/LandRepository.java new file mode 100644 index 000000000..5de1980af --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/repository/LandRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.land.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.land.exception.LandNotFoundException; +import in.koreatech.koin.domain.land.model.Land; + +public interface LandRepository extends Repository { + + List findAll(); + + Optional findById(Integer id); + + Land save(Land request); + + default Land getById(Integer id) { + return findById(id).orElseThrow(() -> LandNotFoundException.withDetail("id: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/land/service/LandService.java b/src/main/java/in/koreatech/koin/domain/land/service/LandService.java new file mode 100644 index 000000000..0977b5f1f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/service/LandService.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.domain.land.service; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.net.URLEncoder; +import java.util.List; + +import org.json.JSONArray; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.land.dto.LandResponse; +import in.koreatech.koin.domain.land.dto.LandsGroupResponse; +import in.koreatech.koin.domain.land.dto.LandsResponse; +import in.koreatech.koin.domain.land.model.Land; +import in.koreatech.koin.domain.land.repository.LandRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LandService { + + private final LandRepository landRepository; + + public LandsGroupResponse getLands() { + List lands = landRepository.findAll() + .stream() + .map(LandsResponse::from) + .toList(); + + return new LandsGroupResponse(lands); + } + + public LandResponse getLand(Integer id) { + Land land = landRepository.getById(id); + + String image = land.getImageUrls(); + List imageUrls = null; + + if (image != null) { + imageUrls = new JSONArray(image) + .toList() + .stream() + .map(Object::toString) + .toList(); + } + + return LandResponse.of(land, imageUrls, URLEncoder.encode(land.getInternalName(), UTF_8)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/member/controller/MemberApi.java b/src/main/java/in/koreatech/koin/domain/member/controller/MemberApi.java new file mode 100644 index 000000000..ddd2725cc --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/controller/MemberApi.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.domain.member.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import in.koreatech.koin.domain.member.dto.MemberResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Member : BCSDLab 회원", description = "BCSDLab 회원 정보를 관리한다") +public interface MemberApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + } + ) + @Operation(summary = "회원 목록 조회") + @GetMapping("/members") + ResponseEntity> getMembers(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 회원 조회") + @GetMapping("/members/{id}") + ResponseEntity getMember( + @PathVariable Integer id + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/member/controller/MemberController.java b/src/main/java/in/koreatech/koin/domain/member/controller/MemberController.java new file mode 100644 index 000000000..ea8176c3f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/controller/MemberController.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.domain.member.controller; + +import java.util.List; + +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.RestController; + +import in.koreatech.koin.domain.member.dto.MemberResponse; +import in.koreatech.koin.domain.member.service.MemberService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class MemberController implements MemberApi { + + private final MemberService memberService; + + @GetMapping("/members") + public ResponseEntity> getMembers() { + var response = memberService.getMembers(); + return ResponseEntity.ok(response); + } + + @GetMapping("/members/{id}") + public ResponseEntity getMember( + @PathVariable Integer id + ) { + var response = memberService.getMember(id); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/member/controller/TrackApi.java b/src/main/java/in/koreatech/koin/domain/member/controller/TrackApi.java new file mode 100644 index 000000000..489b41697 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/controller/TrackApi.java @@ -0,0 +1,45 @@ +package in.koreatech.koin.domain.member.controller; + +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import in.koreatech.koin.domain.member.dto.TrackResponse; +import in.koreatech.koin.domain.member.dto.TrackSingleResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(BCSDLab) Track: BCSDLab 트랙", description = "BCSDLab 트랙 정보를 관리한다") +public interface TrackApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 목록 조회") + @GetMapping("/tracks") + ResponseEntity> getTracks(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 단건 조회") + @GetMapping("/tracks/{id}") + ResponseEntity getTrack( + @Parameter(in = PATH) @PathVariable Integer id + ); +} diff --git a/src/main/java/in/koreatech/koin/controller/TrackController.java b/src/main/java/in/koreatech/koin/domain/member/controller/TrackController.java similarity index 65% rename from src/main/java/in/koreatech/koin/controller/TrackController.java rename to src/main/java/in/koreatech/koin/domain/member/controller/TrackController.java index 236028202..6b9d8cfad 100644 --- a/src/main/java/in/koreatech/koin/controller/TrackController.java +++ b/src/main/java/in/koreatech/koin/domain/member/controller/TrackController.java @@ -1,18 +1,20 @@ -package in.koreatech.koin.controller; +package in.koreatech.koin.domain.member.controller; -import in.koreatech.koin.dto.TrackResponse; -import in.koreatech.koin.dto.TrackSingleResponse; -import in.koreatech.koin.service.TrackService; import java.util.List; -import lombok.RequiredArgsConstructor; + 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.RestController; +import in.koreatech.koin.domain.member.dto.TrackResponse; +import in.koreatech.koin.domain.member.dto.TrackSingleResponse; +import in.koreatech.koin.domain.member.service.TrackService; +import lombok.RequiredArgsConstructor; + @RestController @RequiredArgsConstructor -public class TrackController { +public class TrackController implements TrackApi { private final TrackService trackService; @@ -23,7 +25,9 @@ public ResponseEntity> getTracks() { } @GetMapping("/tracks/{id}") - public ResponseEntity getTrack(@PathVariable Long id) { + public ResponseEntity getTrack( + @PathVariable Integer id + ) { TrackSingleResponse response = trackService.getTrack(id); return ResponseEntity.ok(response); } diff --git a/src/main/java/in/koreatech/koin/domain/member/dto/MemberResponse.java b/src/main/java/in/koreatech/koin/domain/member/dto/MemberResponse.java new file mode 100644 index 000000000..d1082ce86 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/dto/MemberResponse.java @@ -0,0 +1,64 @@ +package in.koreatech.koin.domain.member.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.member.model.Member; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record MemberResponse( + @Schema(example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "최준호", requiredMode = REQUIRED) + String name, + + @Schema(example = "2019136135", requiredMode = NOT_REQUIRED) + String studentNumber, + + @Schema(example = "Backend", requiredMode = NOT_REQUIRED) + String track, + + @Schema(example = "Regular", requiredMode = REQUIRED) + String position, + + @Schema(example = "juno@gmail.com", requiredMode = NOT_REQUIRED) + String email, + + @Schema(example = "https://static.koreatech.in/bcsdlab_page_assets/img/people/juno.jpg", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(example = "false", requiredMode = REQUIRED) + boolean isDeleted, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "생성 일자", example = "2020-08-14 16:26:35", requiredMode = REQUIRED) + LocalDateTime createdAt, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "수정 일자", example = "2021-08-16 06:42:44", requiredMode = REQUIRED) + LocalDateTime updatedAt +) { + + public static MemberResponse from(Member member) { + return new MemberResponse( + member.getId(), + member.getName(), + member.getStudentNumber(), + member.getTrack().getName(), + member.getPosition(), + member.getEmail(), + member.getImageUrl(), + member.isDeleted(), + member.getCreatedAt(), + member.getUpdatedAt() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/member/dto/TrackResponse.java b/src/main/java/in/koreatech/koin/domain/member/dto/TrackResponse.java new file mode 100644 index 000000000..7cf626520 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/dto/TrackResponse.java @@ -0,0 +1,47 @@ +package in.koreatech.koin.domain.member.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.member.model.Track; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public record TrackResponse( + @Schema(description = "트랙 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "트랙 명", example = "Backend", requiredMode = REQUIRED) + String name, + + @Schema(description = "인원 수", example = "15", requiredMode = REQUIRED) + Integer headcount, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + LocalDateTime createdAt, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + LocalDateTime updatedAt +) { + + public static TrackResponse from(Track track) { + return new TrackResponse( + track.getId(), + track.getName(), + track.getHeadcount(), + track.isDeleted(), + track.getCreatedAt(), + track.getUpdatedAt() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/member/dto/TrackSingleResponse.java b/src/main/java/in/koreatech/koin/domain/member/dto/TrackSingleResponse.java new file mode 100644 index 000000000..31286f1f7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/dto/TrackSingleResponse.java @@ -0,0 +1,134 @@ +package in.koreatech.koin.domain.member.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.member.model.Member; +import in.koreatech.koin.domain.member.model.TechStack; +import in.koreatech.koin.domain.member.model.Track; +import io.swagger.v3.oas.annotations.media.Schema; + +public record TrackSingleResponse( + @JsonProperty("TrackName") + @Schema(description = "트랙 명", example = "Backend", requiredMode = REQUIRED) + String trackName, + + @JsonProperty("TechStacks") + List innerTechStackResponses, + + @JsonProperty("Members") + List innerMemberResponses +) { + + public static TrackSingleResponse of(Track track, List members, List techStacks) { + return new TrackSingleResponse( + track.getName(), + techStacks.stream() + .map(InnerTechStackResponse::from) + .toList(), + members.stream() + .map(member -> InnerMemberResponse.from(member, track.getName())) + .toList() + ); + } + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + private record InnerTechStackResponse( + @Schema(description = "기술 스택 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "기술 이름", example = "Backend") + String name, + + @Schema(description = "기술 설명", example = "15") + String description, + + @Schema(description = "이미지 Url", example = "https://static.koreatech.in/example/image.png") + String imageUrl, + + @Schema(description = "트랙 ID", example = "1") + Integer trackId, + + @Schema(description = "삭제 여부", example = "false") + Boolean isDeleted, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt + ) { + + public static InnerTechStackResponse from(TechStack techStack) { + return new InnerTechStackResponse( + techStack.getId(), + techStack.getName(), + techStack.getDescription(), + techStack.getImageUrl(), + techStack.getTrackId(), + techStack.isDeleted(), + techStack.getCreatedAt(), + techStack.getUpdatedAt() + ); + } + } + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + private record InnerMemberResponse( + @Schema(description = "BCSD 회원 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "최준호", requiredMode = REQUIRED) + String name, + + @Schema(description = "학번", example = "2019136135", requiredMode = NOT_REQUIRED) + String studentNumber, + + @Schema(description = "동아리 포지션 `Beginner`, `Regular`, `Mentor`", example = "Regular", requiredMode = REQUIRED) + String position, + + @Schema(description = "트랙 명", example = "Backend", requiredMode = REQUIRED) + String track, + + @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = NOT_REQUIRED) + String email, + + @Schema(description = "이미지 Url", example = "https://static.koreatech.in/example/image.png", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + LocalDateTime createdAt, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + LocalDateTime updatedAt + ) { + + public static InnerMemberResponse from(Member member, String trackName) { + return new InnerMemberResponse( + member.getId(), + member.getName(), + member.getStudentNumber(), + member.getPosition(), + trackName, + member.getEmail(), + member.getImageUrl(), + member.isDeleted(), + member.getCreatedAt(), + member.getUpdatedAt() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/member/exception/MemberNotFoundException.java b/src/main/java/in/koreatech/koin/domain/member/exception/MemberNotFoundException.java new file mode 100644 index 000000000..49da0209e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/exception/MemberNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.member.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class MemberNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 BCSD 회원입니다."; + + public MemberNotFoundException(String message) { + super(message); + } + + public MemberNotFoundException(String message, String detail) { + super(message, detail); + } + + public static MemberNotFoundException withDetail(String detail) { + return new MemberNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/member/exception/TrackNotFoundException.java b/src/main/java/in/koreatech/koin/domain/member/exception/TrackNotFoundException.java new file mode 100644 index 000000000..6150621ae --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/exception/TrackNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.member.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class TrackNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 트랙입니다."; + + public TrackNotFoundException(String message) { + super(message); + } + + public TrackNotFoundException(String message, String detail) { + super(message, detail); + } + + public static TrackNotFoundException withDetail(String detail) { + return new TrackNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/Member.java b/src/main/java/in/koreatech/koin/domain/member/model/Member.java similarity index 60% rename from src/main/java/in/koreatech/koin/domain/Member.java rename to src/main/java/in/koreatech/koin/domain/member/model/Member.java index ee9c76d9b..835345035 100644 --- a/src/main/java/in/koreatech/koin/domain/Member.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/Member.java @@ -1,27 +1,34 @@ -package in.koreatech.koin.domain; +package in.koreatech.koin.domain.member.model; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Lob; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +/** + * BCSDLab 회원에 대한 정보를 다루는 엔티티 + */ @Getter @Entity @Table(name = "members") -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = PROTECTED) public class Member extends BaseEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = IDENTITY) private Integer id; @Size(max = 50) @@ -34,8 +41,9 @@ public class Member extends BaseEntity { private String studentNumber; @NotNull - @Column(name = "track_id") - private Long trackId; + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "track_id") + private Track track; @Size(max = 20) @NotNull @@ -46,20 +54,26 @@ public class Member extends BaseEntity { @Column(name = "email", length = 100) private String email; - @Lob @Column(name = "image_url") private String imageUrl; @NotNull @Column(name = "is_deleted", nullable = false) - private Boolean isDeleted = false; + private boolean isDeleted = false; @Builder - public Member(String name, String studentNumber, Long trackId, String position, String email, String imageUrl, - Boolean isDeleted) { + private Member( + String name, + String studentNumber, + Track track, + String position, + String email, + String imageUrl, + boolean isDeleted + ) { this.name = name; this.studentNumber = studentNumber; - this.trackId = trackId; + this.track = track; this.position = position; this.email = email; this.imageUrl = imageUrl; diff --git a/src/main/java/in/koreatech/koin/domain/TechStack.java b/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java similarity index 66% rename from src/main/java/in/koreatech/koin/domain/TechStack.java rename to src/main/java/in/koreatech/koin/domain/member/model/TechStack.java index 624edc356..b09379ad9 100644 --- a/src/main/java/in/koreatech/koin/domain/TechStack.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java @@ -1,31 +1,30 @@ -package in.koreatech.koin.domain; +package in.koreatech.koin.domain.member.model; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Lob; import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; - @Getter @Entity @Table(name = "tech_stacks") -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = PROTECTED) public class TechStack extends BaseEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @GeneratedValue(strategy = IDENTITY) + private Integer id; - @Lob @Column(name = "image_url") private String imageUrl; @@ -39,18 +38,22 @@ public class TechStack extends BaseEntity { private String description; @Column(name = "track_id") - private Long trackId; + private Integer trackId; @NotNull @Column(name = "is_deleted", nullable = false) - private Boolean isDeleted = false; + private boolean isDeleted = false; @Builder - private TechStack(String imageUrl, String name, String description, Long trackId, Boolean isDeleted) { + private TechStack( + String imageUrl, + String name, + String description, + Integer trackId + ) { this.imageUrl = imageUrl; this.name = name; this.description = description; this.trackId = trackId; - this.isDeleted = isDeleted; } } diff --git a/src/main/java/in/koreatech/koin/domain/Track.java b/src/main/java/in/koreatech/koin/domain/member/model/Track.java similarity index 79% rename from src/main/java/in/koreatech/koin/domain/Track.java rename to src/main/java/in/koreatech/koin/domain/member/model/Track.java index 6c731e782..423d9bed4 100644 --- a/src/main/java/in/koreatech/koin/domain/Track.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/Track.java @@ -1,5 +1,8 @@ -package in.koreatech.koin.domain; +package in.koreatech.koin.domain.member.model; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -8,7 +11,6 @@ import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,12 +18,12 @@ @Entity @Getter @Table(name = "tracks") -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = PROTECTED) public class Track extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + private Integer id; @Size(max = 50) @NotNull @@ -34,7 +36,7 @@ public class Track extends BaseEntity { @NotNull @Column(name = "is_deleted", nullable = false) - private Boolean isDeleted = false; + private boolean isDeleted = false; @Builder private Track(String name) { diff --git a/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java b/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java new file mode 100644 index 000000000..0478b4261 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.member.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.member.exception.MemberNotFoundException; +import in.koreatech.koin.domain.member.model.Member; + +public interface MemberRepository extends Repository { + + Member save(Member member); + + List findAllByTrackId(Integer id); + + List findAll(); + + Optional findById(Integer id); + + default Member getById(Integer id) { + return findById(id) + .orElseThrow(() -> MemberNotFoundException.withDetail("id: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/repository/TechStackRepository.java b/src/main/java/in/koreatech/koin/domain/member/repository/TechStackRepository.java similarity index 52% rename from src/main/java/in/koreatech/koin/repository/TechStackRepository.java rename to src/main/java/in/koreatech/koin/domain/member/repository/TechStackRepository.java index 2839ba3c4..018e02ee6 100644 --- a/src/main/java/in/koreatech/koin/repository/TechStackRepository.java +++ b/src/main/java/in/koreatech/koin/domain/member/repository/TechStackRepository.java @@ -1,12 +1,14 @@ -package in.koreatech.koin.repository; +package in.koreatech.koin.domain.member.repository; -import in.koreatech.koin.domain.TechStack; import java.util.List; + import org.springframework.data.repository.Repository; -public interface TechStackRepository extends Repository { +import in.koreatech.koin.domain.member.model.TechStack; + +public interface TechStackRepository extends Repository { TechStack save(TechStack techStack); - List findAllByTrackId(Long id); + List findAllByTrackId(Integer id); } diff --git a/src/main/java/in/koreatech/koin/domain/member/repository/TrackRepository.java b/src/main/java/in/koreatech/koin/domain/member/repository/TrackRepository.java new file mode 100644 index 000000000..aa310cdb1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/repository/TrackRepository.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.member.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.member.exception.TrackNotFoundException; +import in.koreatech.koin.domain.member.model.Track; + +public interface TrackRepository extends Repository { + + Track save(Track track); + + List findAll(); + + Optional findById(Integer trackId); + + default Track getById(Integer trackId) { + return findById(trackId) + .orElseThrow(() -> TrackNotFoundException.withDetail("trackId: " + trackId)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/member/service/MemberService.java b/src/main/java/in/koreatech/koin/domain/member/service/MemberService.java new file mode 100644 index 000000000..c2f3c3306 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/member/service/MemberService.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.domain.member.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.member.dto.MemberResponse; +import in.koreatech.koin.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + + public List getMembers() { + var members = memberRepository.findAll(); + return members.stream() + .map(MemberResponse::from) + .toList(); + } + + public MemberResponse getMember(Integer id) { + var member = memberRepository.getById(id); + return MemberResponse.from(member); + } +} diff --git a/src/main/java/in/koreatech/koin/service/TrackService.java b/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java similarity index 52% rename from src/main/java/in/koreatech/koin/service/TrackService.java rename to src/main/java/in/koreatech/koin/domain/member/service/TrackService.java index b08cdcfe6..1677f76fd 100644 --- a/src/main/java/in/koreatech/koin/service/TrackService.java +++ b/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java @@ -1,17 +1,19 @@ -package in.koreatech.koin.service; - -import in.koreatech.koin.domain.Member; -import in.koreatech.koin.domain.TechStack; -import in.koreatech.koin.domain.Track; -import in.koreatech.koin.dto.TrackResponse; -import in.koreatech.koin.dto.TrackSingleResponse; -import in.koreatech.koin.repository.MemberRepository; -import in.koreatech.koin.repository.TechStackRepository; -import in.koreatech.koin.repository.TrackRepository; +package in.koreatech.koin.domain.member.service; + import java.util.List; -import lombok.RequiredArgsConstructor; + import org.springframework.stereotype.Service; +import in.koreatech.koin.domain.member.dto.TrackResponse; +import in.koreatech.koin.domain.member.dto.TrackSingleResponse; +import in.koreatech.koin.domain.member.model.Member; +import in.koreatech.koin.domain.member.model.TechStack; +import in.koreatech.koin.domain.member.model.Track; +import in.koreatech.koin.domain.member.repository.MemberRepository; +import in.koreatech.koin.domain.member.repository.TechStackRepository; +import in.koreatech.koin.domain.member.repository.TrackRepository; +import lombok.RequiredArgsConstructor; + @Service @RequiredArgsConstructor public class TrackService { @@ -26,9 +28,8 @@ public List getTracks() { .toList(); } - public TrackSingleResponse getTrack(Long id) { - Track track = trackRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 트랙입니다.")); + public TrackSingleResponse getTrack(Integer id) { + Track track = trackRepository.getById(id); List member = memberRepository.findAllByTrackId(id); List techStacks = techStackRepository.findAllByTrackId(id); diff --git a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java new file mode 100644 index 000000000..eec0f3a03 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java @@ -0,0 +1,142 @@ +package in.koreatech.koin.domain.owner.controller; + +import static in.koreatech.koin.domain.user.model.UserType.OWNER; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateRequest; +import in.koreatech.koin.domain.owner.dto.OwnerRegisterRequest; +import in.koreatech.koin.domain.owner.dto.OwnerResponse; +import in.koreatech.koin.domain.owner.dto.OwnerSendEmailRequest; +import in.koreatech.koin.domain.owner.dto.OwnerVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerVerifyResponse; +import in.koreatech.koin.domain.owner.dto.VerifyEmailRequest; +import in.koreatech.koin.domain.owner.dto.VerifyPhoneRequest; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Owner: 사장님", description = "사장님 정보를 관리한다.") +public interface OwnerApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원가입 인증번호 전송 요청") + @PostMapping("/owners/verification/email") + ResponseEntity requestVerificationToRegisterByEmail( + @RequestBody @Valid VerifyEmailRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원가입 문자 인증번호 전송 요청") + @PostMapping("/owners/verification/phone") + ResponseEntity requestVerificationToRegisterByPhone( + @RequestBody @Valid VerifyPhoneRequest verifyPhoneRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 정보 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/owner") + ResponseEntity getOwner( + @Auth(permit = {OWNER}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 회원가입") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/owners/register") + ResponseEntity register( + @Valid @RequestBody OwnerRegisterRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 인증번호 입력") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/owners/verification/code") + ResponseEntity codeVerification( + @Valid @RequestBody OwnerVerifyRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 비밀번호 변경 인증번호 이메일 발송") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/owners/password/reset/verification") + ResponseEntity sendResetPasswordEmail( + @Valid @RequestBody OwnerSendEmailRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 비밀번호 변경 인증번호 인증") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/owners/password/reset/send") + ResponseEntity sendVerifyCode( + @Valid @RequestBody OwnerPasswordResetVerifyRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 비밀번호 변경") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/owners/password/reset") + ResponseEntity updatePassword( + @Valid @RequestBody OwnerPasswordUpdateRequest request + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java new file mode 100644 index 000000000..fa768c82f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java @@ -0,0 +1,95 @@ +package in.koreatech.koin.domain.owner.controller; + +import static in.koreatech.koin.domain.user.model.UserType.OWNER; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateRequest; +import in.koreatech.koin.domain.owner.dto.OwnerRegisterRequest; +import in.koreatech.koin.domain.owner.dto.OwnerResponse; +import in.koreatech.koin.domain.owner.dto.OwnerSendEmailRequest; +import in.koreatech.koin.domain.owner.dto.OwnerVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerVerifyResponse; +import in.koreatech.koin.domain.owner.dto.VerifyEmailRequest; +import in.koreatech.koin.domain.owner.dto.VerifyPhoneRequest; +import in.koreatech.koin.domain.owner.service.OwnerService; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class OwnerController implements OwnerApi { + + private final OwnerService ownerService; + + @PostMapping("/owners/verification/email") + public ResponseEntity requestVerificationToRegisterByEmail( + @RequestBody @Valid VerifyEmailRequest request + ) { + ownerService.requestSignUpEmailVerification(request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/owners/verification/phone") + public ResponseEntity requestVerificationToRegisterByPhone( + @RequestBody @Valid VerifyPhoneRequest verifyPhoneRequest + ) { + ownerService.requestSignUpPhoneVerification(verifyPhoneRequest); + return ResponseEntity.ok().build(); + } + + @GetMapping("/owner") + public ResponseEntity getOwner( + @Auth(permit = {OWNER}) Integer ownerId + ) { + OwnerResponse ownerInfo = ownerService.getOwner(ownerId); + return ResponseEntity.ok().body(ownerInfo); + } + + @PostMapping("/owners/register") + public ResponseEntity register( + @Valid @RequestBody OwnerRegisterRequest request + ) { + ownerService.register(request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/owners/verification/code") + public ResponseEntity codeVerification( + @Valid @RequestBody OwnerVerifyRequest request + ) { + OwnerVerifyResponse response = ownerService.verifyCode(request); + return ResponseEntity.ok().body(response); + } + + @PostMapping("/owners/password/reset/verification") + public ResponseEntity sendResetPasswordEmail( + @Valid @RequestBody OwnerSendEmailRequest request + ) { + ownerService.sendResetPasswordEmail(request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/owners/password/reset/send") + public ResponseEntity sendVerifyCode( + @Valid @RequestBody OwnerPasswordResetVerifyRequest request + ) { + ownerService.verifyResetPasswordCode(request); + return ResponseEntity.ok().build(); + } + + @PutMapping("/owners/password/reset") + public ResponseEntity updatePassword( + @Valid @RequestBody OwnerPasswordUpdateRequest request + ) { + ownerService.updatePassword(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerPasswordResetVerifyRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerPasswordResetVerifyRequest.java new file mode 100644 index 000000000..1399c0af9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerPasswordResetVerifyRequest.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +@JsonNaming(SnakeCaseStrategy.class) +public record OwnerPasswordResetVerifyRequest( + @JsonProperty(value = "address") + @Email(message = "이메일 형식이 올바르지 않습니다. ${validatedValue}") + @NotBlank(message = "이메일은 필수입니다.") + @Schema(description = "사장님 이메일", example = "junho5336@gmail.com", requiredMode = REQUIRED) + String email, + + @NotBlank(message = "인증 코드는 필수입니다.") + @Digits(integer = 6, fraction = 0, message = "인증 코드는 6자리 정수여야 합니다.") + @Schema(description = "인증 코드", example = "123456", requiredMode = REQUIRED) + String certificationCode +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerPasswordUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerPasswordUpdateRequest.java new file mode 100644 index 000000000..36a2d63ad --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerPasswordUpdateRequest.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; + +public record OwnerPasswordUpdateRequest( + @NotNull(message = "비밀번호는 비워둘 수 없습니다.") + @Schema(description = "비밀번호", example = "a0240120305812krlakdsflsa;1235", requiredMode = REQUIRED) + String password, + + @Email(message = "이메일 형식이 올바르지 않습니다. ${validatedValue}") + @NotNull(message = "이메일은 필수입니다.") + @Schema(description = "이메일 주소", example = "asdf@gmail.com", requiredMode = REQUIRED) + @JsonProperty(value = "address") + String email +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java new file mode 100644 index 000000000..1555a62ff --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java @@ -0,0 +1,102 @@ +package in.koreatech.koin.domain.owner.dto; + +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.validator.constraints.URL; +import org.springframework.security.crypto.password.PasswordEncoder; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.owner.model.OwnerAttachment; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record OwnerRegisterRequest( + + @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}", message = "사업자 등록 번호 형식이 올바르지 않습니다. ${validatedValue}") + @Schema(description = "사업자 등록 번호", example = "012-34-56789", requiredMode = NOT_REQUIRED) + String companyNumber, + + @Email(message = "이메일 형식이 올바르지 않습니다. ${validatedValue}") + @NotBlank(message = "이메일은 필수입니다.") + @Schema(description = "이메일", example = "junho5336@gmail.com", requiredMode = REQUIRED) + String email, + + @NotBlank(message = "이름은 필수입니다.") + @Size(max = 50, message = "이름은 50자 이내여야 합니다.") + @Schema(description = "이름", example = "최준호", requiredMode = REQUIRED) + String name, + + @NotBlank(message = "비밀번호는 필수입니다.") + @Schema(description = "비밀번호", example = "password", requiredMode = REQUIRED) + String password, + + @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}", message = "전화번호 형식이 올바르지 않습니다.") + @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = REQUIRED) + String phoneNumber, + + @Schema(description = "상점 고유 ID", requiredMode = NOT_REQUIRED) + Integer shopId, + + @Schema(description = "상점 이름", example = "고릴라밥", requiredMode = NOT_REQUIRED) + String shopName, + + @Size(min = 1, max = 5, message = "이미지는 사업자등록증, 영업신고증, 통장사본을 포함하여 최소 1개 최대 5개까지 가능합니다.") + @Schema(description = "첨부 이미지들", requiredMode = REQUIRED) + @Valid + List attachmentUrls +) { + + public Owner toOwner(PasswordEncoder passwordEncoder) { + var user = User.builder() + .password(passwordEncoder.encode(password)) + .email(email) + .name(name) + .phoneNumber(phoneNumber) + .userType(OWNER) + .isAuthed(false) + .isDeleted(false) + .build(); + Owner owner = Owner.builder() + .user(user) + .companyRegistrationNumber(companyNumber) + .attachments(new ArrayList<>()) + .grantShop(false) + .grantEvent(false) + .build(); + var attachments = attachmentUrls.stream() + .map(InnerAttachmentUrl::fileUrl) + .map(fileUrl -> OwnerAttachment.builder() + .url(fileUrl) + .owner(owner) + .isDeleted(false) + .name(name) + .build()) + .toList(); + owner.getAttachments().addAll(attachments); + return owner; + } + + @JsonNaming(SnakeCaseStrategy.class) + public record InnerAttachmentUrl( + @NotBlank(message = "첨부 파일 URL은 필수입니다.") + @URL(protocol = "https", regexp = ".*static\\.koreatech\\.in.*", message = "코인 파일 저장 형식이 아닙니다.") + @Schema(description = "첨부 파일 URL (코인 파일 형식이어야 함)", example = "https://static.koreatech.in/1.png", requiredMode = REQUIRED) + String fileUrl + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java new file mode 100644 index 000000000..d0d2180c9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java @@ -0,0 +1,84 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.owner.model.OwnerAttachment; +import in.koreatech.koin.domain.shop.model.Shop; +import io.swagger.v3.oas.annotations.media.Schema; + +public record OwnerResponse( + @Schema(description = "이메일", example = "example@gmail.com", requiredMode = REQUIRED) + String email, + + @Schema(description = "이름", example = "홍길동", requiredMode = REQUIRED) + String name, + + @Schema(description = "사업자 등록 번호", example = "123-45-67890", requiredMode = REQUIRED) + String company_number, + + @Schema(description = "첨부 파일 목록", requiredMode = NOT_REQUIRED) + List attachments, + + @Schema(description = "가게 목록", requiredMode = NOT_REQUIRED) + List shops +) { + + public static OwnerResponse of(Owner owner, List attachments, List shops) { + return new OwnerResponse( + owner.getUser().getEmail(), + owner.getUser().getName(), + owner.getCompanyRegistrationNumber(), + attachments.stream() + .map(InnerAttachmentResponse::from) + .toList(), + shops.stream() + .map(InnerShopResponse::from) + .toList() + ); + } + + @JsonNaming(SnakeCaseStrategy.class) + private record InnerAttachmentResponse( + @Schema(description = "첨부 파일 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "첨부 파일 URL", example = "https://static.koreatech.in/1.png", requiredMode = REQUIRED) + String fileUrl, + + @Schema(description = "첨부 파일 이름", example = "1.jpg", requiredMode = REQUIRED) + String fileName + ) { + + public static InnerAttachmentResponse from(OwnerAttachment ownerAttachment) { + return new InnerAttachmentResponse( + ownerAttachment.getId(), + ownerAttachment.getUrl(), + ownerAttachment.getName() + ); + } + } + + @JsonNaming(SnakeCaseStrategy.class) + private record InnerShopResponse( + @Schema(description = "가게 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "가게 이름", example = "가게1", requiredMode = REQUIRED) + String name + ) { + + public static InnerShopResponse from(Shop shop) { + return new InnerShopResponse( + shop.getId(), + shop.getName() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerSendEmailRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerSendEmailRequest.java new file mode 100644 index 000000000..241f09eb2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerSendEmailRequest.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record OwnerSendEmailRequest( + @Email(message = "이메일 형식이 올바르지 않습니다. ${validatedValue}") + @NotBlank(message = "이메일은 필수입니다.") + @JsonProperty(value = "address") + @Schema(description = "이메일", example = "temp@gmail.com", requiredMode = REQUIRED) + String email +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerVerifyRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerVerifyRequest.java new file mode 100644 index 000000000..626820f24 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerVerifyRequest.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; + +@JsonNaming(SnakeCaseStrategy.class) +public record OwnerVerifyRequest( + @JsonProperty(value = "address") + @NotBlank(message = "검증값은 필수입니다.") + @Schema(description = "검증값 (전화번호, 이메일)", example = "01012341234", requiredMode = REQUIRED) + String email, + + @NotBlank(message = "인증 코드는 필수입니다.") + @Digits(integer = 6, fraction = 0, message = "인증 코드는 6자리 정수여야 합니다. ${validatedValue}") + @Schema(description = "인증 코드", example = "123456", requiredMode = REQUIRED) + String certificationCode +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerVerifyResponse.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerVerifyResponse.java new file mode 100644 index 000000000..ccf028c8c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerVerifyResponse.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record OwnerVerifyResponse( + @Schema( + description = "임시 액세스 토큰", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + String token +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/VerifyEmailRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/VerifyEmailRequest.java new file mode 100644 index 000000000..c4f71c7da --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/VerifyEmailRequest.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record VerifyEmailRequest( + @JsonProperty("address") + @Email(message = "이메일 형식이 올바르지 않습니다. ${validatedValue}") + @NotBlank(message = "이메일은 필수입니다.") + @Schema(description = "이메일", example = "temp@gmail.com", requiredMode = REQUIRED) + String email +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/VerifyPhoneRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/VerifyPhoneRequest.java new file mode 100644 index 000000000..dc36557d3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/VerifyPhoneRequest.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record VerifyPhoneRequest( + @Size(min = 10, max = 11) + @NotBlank(message = "휴대폰번호는 필수입니다.") + @Schema(description = "휴대폰번호", example = "01012341234", requiredMode = REQUIRED) + String phoneNumber +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/exception/AttachmentNotFoundException.java b/src/main/java/in/koreatech/koin/domain/owner/exception/AttachmentNotFoundException.java new file mode 100644 index 000000000..4413fc886 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/exception/AttachmentNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.owner.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class AttachmentNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 첨부파일입니다."; + + public AttachmentNotFoundException(String message) { + super(message); + } + + public AttachmentNotFoundException(String message, String detail) { + super(message, detail); + } + + public static AttachmentNotFoundException withDetail(String detail) { + return new AttachmentNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/exception/DuplicationCertificationException.java b/src/main/java/in/koreatech/koin/domain/owner/exception/DuplicationCertificationException.java new file mode 100644 index 000000000..c7bc4857b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/exception/DuplicationCertificationException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.owner.exception; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class DuplicationCertificationException extends DuplicationException { + + private static final String DEFAULT_MESSAGE = "이미 인증이 완료되었습니다."; + + public DuplicationCertificationException(String message) { + super(message); + } + + public DuplicationCertificationException(String message, String detail) { + super(message, detail); + } + + public static DuplicationCertificationException withDetail(String detail) { + return new DuplicationCertificationException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/exception/DuplicationCompanyNumberException.java b/src/main/java/in/koreatech/koin/domain/owner/exception/DuplicationCompanyNumberException.java new file mode 100644 index 000000000..11700e120 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/exception/DuplicationCompanyNumberException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.owner.exception; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class DuplicationCompanyNumberException extends DuplicationException { + + private static final String DEFAULT_MESSAGE = "이미 존재하는 사업자 등록번호입니다."; + + public DuplicationCompanyNumberException(String message) { + super(message); + } + + public DuplicationCompanyNumberException(String message, String detail) { + super(message, detail); + } + + public static DuplicationCompanyNumberException withDetail(String detail) { + return new DuplicationCompanyNumberException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/exception/OwnerAttachmentNotFoundException.java b/src/main/java/in/koreatech/koin/domain/owner/exception/OwnerAttachmentNotFoundException.java new file mode 100644 index 000000000..144685078 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/exception/OwnerAttachmentNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.owner.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class OwnerAttachmentNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "해당 첨부파일을 찾을 수 없습니다."; + + public OwnerAttachmentNotFoundException(String message) { + super(message); + } + + public OwnerAttachmentNotFoundException(String message, String detail) { + super(message, detail); + } + + public static OwnerAttachmentNotFoundException withDetail(String detail) { + return new OwnerAttachmentNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/exception/OwnerNotFoundException.java b/src/main/java/in/koreatech/koin/domain/owner/exception/OwnerNotFoundException.java new file mode 100644 index 000000000..094becd7a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/exception/OwnerNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.owner.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class OwnerNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 사장님입니다."; + + public OwnerNotFoundException(String message) { + super(message); + } + + public OwnerNotFoundException(String message, String detail) { + super(message, detail); + } + + public static OwnerNotFoundException withDetail(String detail) { + return new OwnerNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/EmailVerifyRequest.java b/src/main/java/in/koreatech/koin/domain/owner/model/EmailVerifyRequest.java new file mode 100644 index 000000000..0a4f0a994 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/EmailVerifyRequest.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.domain.owner.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import lombok.Getter; + +/** + * 이메일 재요청 시간제한 + */ +@Getter +@RedisHash(value = "emailverify@") +public class EmailVerifyRequest { + + private static final long CACHE_EXPIRE_SECOND = 60L; + + @Id + private String email; + + @TimeToLive + private Long expiration; + + public EmailVerifyRequest(String email) { + this.email = email; + this.expiration = CACHE_EXPIRE_SECOND; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java b/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java new file mode 100644 index 000000000..384df6fb9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.domain.owner.model; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.CascadeType.MERGE; +import static jakarta.persistence.CascadeType.PERSIST; +import static jakarta.persistence.CascadeType.REMOVE; +import static lombok.AccessLevel.PROTECTED; + +import java.util.ArrayList; +import java.util.List; + +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "owners") +public class Owner { + + @Id + @Column(name = "user_id", nullable = false) + private Integer id; + + @MapsId + @OneToOne(cascade = ALL) + @JoinColumn(name = "user_id", referencedColumnName = "id") + private User user; + + @Size(max = 12) + @NotNull + @Column(name = "company_registration_number", nullable = false, length = 12, unique = true) + private String companyRegistrationNumber; + + @Column(name = "grant_shop", columnDefinition = "TINYINT") + private boolean grantShop; + + @Column(name = "grant_event", columnDefinition = "TINYINT") + private boolean grantEvent; + + @OneToMany(cascade = {PERSIST, MERGE, REMOVE}, orphanRemoval = true) + @JoinColumn(name = "owner_id") + private List attachments = new ArrayList<>(); + + @Builder + private Owner( + User user, + String companyRegistrationNumber, + List attachments, + Boolean grantShop, + Boolean grantEvent + ) { + this.user = user; + this.companyRegistrationNumber = companyRegistrationNumber; + this.attachments = attachments; + this.grantShop = grantShop; + this.grantEvent = grantEvent; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerAttachment.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerAttachment.java new file mode 100644 index 000000000..81279bba0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerAttachment.java @@ -0,0 +1,75 @@ +package in.koreatech.koin.domain.owner.model; + +import static jakarta.persistence.CascadeType.MERGE; +import static jakarta.persistence.CascadeType.PERSIST; +import static jakarta.persistence.CascadeType.REMOVE; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.domain.owner.exception.AttachmentNotFoundException; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PostLoad; +import jakarta.persistence.PostPersist; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Where(clause = "is_deleted=0") +@Table(name = "owner_attachments") +@NoArgsConstructor(access = PROTECTED) +public class OwnerAttachment extends BaseEntity { + + private static final String NAME_SEPARATOR = "/"; + private static final int NOT_FOUND_IDX = -1; + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(cascade = {PERSIST, MERGE, REMOVE}) + @JoinColumn(name = "owner_id") + private Owner owner; + + @NotNull + @Column(name = "url", nullable = false) + private String url; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Transient + private String name; + + @PostPersist + @PostLoad + public void updateName() { + int separateIndex = url.lastIndexOf(NAME_SEPARATOR); + if (separateIndex == NOT_FOUND_IDX) { + throw AttachmentNotFoundException.withDetail("코인 파일 저장 형식(static.koreatech.in)이 아닙니다. url: " + url); + } + + name = url.substring(separateIndex + NAME_SEPARATOR.length()); + } + + @Builder + private OwnerAttachment(String url, Owner owner, Boolean isDeleted, String name) { + this.url = url; + this.owner = owner; + this.isDeleted = isDeleted; + this.name = name; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEmailRequestEvent.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEmailRequestEvent.java new file mode 100644 index 000000000..394167022 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEmailRequestEvent.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.owner.model; + +public record OwnerEmailRequestEvent( + String email +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java new file mode 100644 index 000000000..4abf092e5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java @@ -0,0 +1,59 @@ +package in.koreatech.koin.domain.owner.model; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import in.koreatech.koin.domain.owner.repository.OwnerInVerificationRedisRepository; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.repository.ShopRepository; +import in.koreatech.koin.global.domain.slack.SlackClient; +import in.koreatech.koin.global.domain.slack.model.SlackNotificationFactory; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional(propagation = Propagation.REQUIRES_NEW) +public class OwnerEventListener { + + private final SlackClient slackClient; + private final ShopRepository shopRepository; + private final SlackNotificationFactory slackNotificationFactory; + private final OwnerInVerificationRedisRepository ownerInVerificationRedisRepository; + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onOwnerEmailRequest(OwnerEmailRequestEvent event) { + var notification = slackNotificationFactory.generateOwnerEmailVerificationRequestNotification(event.email()); + slackClient.sendMessage(notification); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onOwnerPhoneRequest(OwnerPhoneRequestEvent ownerPhoneRequestEvent) { + var notification = slackNotificationFactory.generateOwnerPhoneVerificationRequestNotification( + ownerPhoneRequestEvent.phoneNumber()); + slackClient.sendMessage(notification); + } + + /** + * 사장님 회원가입 시 상점 id Redis 임시저장 + *

+ * 추후 어드민에서 승인 시 상점 id를 기준으로 업데이트 수행 + */ + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onOwnerRegister(OwnerRegisterEvent event) { + Owner owner = event.owner(); + ownerInVerificationRedisRepository.deleteByVerify(owner.getUser().getEmail()); + String shopsName = shopRepository.findAllByOwnerId(owner.getId()) + .stream().map(Shop::getName).collect(Collectors.joining(", ")); + var notification = slackNotificationFactory.generateOwnerRegisterRequestNotification( + owner.getUser().getName(), + shopsName + ); + slackClient.sendMessage(notification); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerInVerification.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerInVerification.java new file mode 100644 index 000000000..8d96526f8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerInVerification.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.owner.model; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import lombok.Getter; + +@Getter +@RedisHash(value = "owner@") +public class OwnerInVerification { + + private static final long CACHE_EXPIRE_HOUR = 2L; + + @Id + private String key; + private String certificationCode; + private boolean isAuthed = false; + + @TimeToLive(unit = TimeUnit.HOURS) + private Long expiration; + + public OwnerInVerification(String key, String certificationCode) { + this.key = key; + this.certificationCode = certificationCode; + this.expiration = CACHE_EXPIRE_HOUR; + } + + public void verify() { + this.isAuthed = true; + } + + public static OwnerInVerification of(String key, String certificationCode) { + return new OwnerInVerification(key, certificationCode); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerPhoneRequestEvent.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerPhoneRequestEvent.java new file mode 100644 index 000000000..604c389db --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerPhoneRequestEvent.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.owner.model; + +public record OwnerPhoneRequestEvent( + String phoneNumber +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterEvent.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterEvent.java new file mode 100644 index 000000000..2ceb5f290 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterEvent.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.owner.model; + +public record OwnerRegisterEvent( + Owner owner +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerShop.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerShop.java new file mode 100644 index 000000000..bbb3a3475 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerShop.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.owner.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@RedisHash(value = "owner_shop@", timeToLive = 60 * 60 * 2) +public class OwnerShop { + + @Id + private Integer ownerId; + private Integer shopId; + + @Builder + private OwnerShop(Integer ownerId, Integer shopId) { + this.ownerId = ownerId; + this.shopId = shopId; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/repository/EmailVerifyRequestRedisRepository.java b/src/main/java/in/koreatech/koin/domain/owner/repository/EmailVerifyRequestRedisRepository.java new file mode 100644 index 000000000..c55b9349d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/repository/EmailVerifyRequestRedisRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.owner.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.owner.model.EmailVerifyRequest; + +public interface EmailVerifyRequestRedisRepository extends Repository { + + EmailVerifyRequest save(EmailVerifyRequest emailVerifyRequest); + + Optional findById(String email); +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerAttachmentRepository.java b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerAttachmentRepository.java new file mode 100644 index 000000000..e6138c2c8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerAttachmentRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.owner.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.owner.exception.OwnerAttachmentNotFoundException; +import in.koreatech.koin.domain.owner.model.OwnerAttachment; + +public interface OwnerAttachmentRepository extends Repository { + + OwnerAttachment save(OwnerAttachment ownerAttachment); + + Optional findById(Integer id); + + default OwnerAttachment getById(Integer id) { + return findById(id) + .orElseThrow(() -> OwnerAttachmentNotFoundException.withDetail("id: " + id)); + } + + void deleteByOwnerId(Integer ownerId); +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerInVerificationRedisRepository.java b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerInVerificationRedisRepository.java new file mode 100644 index 000000000..0e45e94ab --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerInVerificationRedisRepository.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.owner.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.owner.model.OwnerInVerification; +import in.koreatech.koin.global.domain.email.exception.VerifyNotFoundException; + +public interface OwnerInVerificationRedisRepository extends Repository { + + OwnerInVerification save(OwnerInVerification ownerInVerification); + + Optional findById(String email); + + void deleteById(String email); + + default void deleteByVerify(String email) { + deleteById(email); + } + + default OwnerInVerification getByVerify(String verify) { + return findById(verify).orElseThrow(() -> VerifyNotFoundException.withDetail("verify: " + verify)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java new file mode 100644 index 000000000..fba5c750b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.owner.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.owner.exception.OwnerNotFoundException; +import in.koreatech.koin.domain.owner.model.Owner; + +public interface OwnerRepository extends Repository { + + Optional findById(Integer ownerId); + + default Owner getById(Integer ownerId) { + return findById(ownerId).orElseThrow(() -> OwnerNotFoundException.withDetail("ownerId: " + ownerId)); + } + + Owner save(Owner owner); + + Optional findByCompanyRegistrationNumber(String companyRegistrationNumber); + + void deleteByUserId(Integer userId); +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerShopRedisRepository.java b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerShopRedisRepository.java new file mode 100644 index 000000000..773340c98 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerShopRedisRepository.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.domain.owner.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.owner.model.OwnerShop; + +public interface OwnerShopRedisRepository extends Repository { + + OwnerShop save(OwnerShop ownerShop); + + OwnerShop findById(Integer ownerId); +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java new file mode 100644 index 000000000..574501c16 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java @@ -0,0 +1,178 @@ +package in.koreatech.koin.domain.owner.service; + +import java.time.Clock; +import java.util.List; +import java.util.Objects; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateRequest; +import in.koreatech.koin.domain.owner.dto.OwnerRegisterRequest; +import in.koreatech.koin.domain.owner.dto.OwnerResponse; +import in.koreatech.koin.domain.owner.dto.OwnerSendEmailRequest; +import in.koreatech.koin.domain.owner.dto.OwnerVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerVerifyResponse; +import in.koreatech.koin.domain.owner.dto.VerifyEmailRequest; +import in.koreatech.koin.domain.owner.dto.VerifyPhoneRequest; +import in.koreatech.koin.domain.owner.exception.DuplicationCertificationException; +import in.koreatech.koin.domain.owner.exception.DuplicationCompanyNumberException; +import in.koreatech.koin.domain.owner.model.EmailVerifyRequest; +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.owner.model.OwnerEmailRequestEvent; +import in.koreatech.koin.domain.owner.model.OwnerInVerification; +import in.koreatech.koin.domain.owner.model.OwnerPhoneRequestEvent; +import in.koreatech.koin.domain.owner.model.OwnerRegisterEvent; +import in.koreatech.koin.domain.owner.model.OwnerShop; +import in.koreatech.koin.domain.owner.repository.EmailVerifyRequestRedisRepository; +import in.koreatech.koin.domain.owner.repository.OwnerInVerificationRedisRepository; +import in.koreatech.koin.domain.owner.repository.OwnerRepository; +import in.koreatech.koin.domain.owner.repository.OwnerShopRedisRepository; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.repository.ShopRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; +import in.koreatech.koin.global.domain.email.form.OwnerPasswordChangeData; +import in.koreatech.koin.global.domain.email.form.OwnerRegistrationData; +import in.koreatech.koin.global.domain.email.service.MailService; +import in.koreatech.koin.global.domain.random.model.CertificateNumberGenerator; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import in.koreatech.koin.global.exception.RequestTooFastException; +import in.koreatech.koin.global.naver.service.NaverSmsService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OwnerService { + + private final JwtProvider jwtProvider; + private final Clock clock; + private final MailService mailService; + private final UserRepository userRepository; + private final ShopRepository shopRepository; + private final OwnerRepository ownerRepository; + private final PasswordEncoder passwordEncoder; + private final ApplicationEventPublisher eventPublisher; + private final OwnerShopRedisRepository ownerShopRedisRepository; + private final OwnerInVerificationRedisRepository ownerInVerificationRedisRepository; + private final EmailVerifyRequestRedisRepository emailVerifyRequestRedisRepository; + private final NaverSmsService naverSmsService; + + @Transactional + public void requestSignUpEmailVerification(VerifyEmailRequest request) { + emailVerifyRequestRedisRepository.findById(request.email()).ifPresent(it -> { + throw new RequestTooFastException("요청이 너무 빠릅니다. %d초 뒤에 다시 시도해주세요".formatted(it.getExpiration())); + }); + userRepository.findByEmail(request.email()).ifPresent(user -> { + throw DuplicationEmailException.withDetail("email: " + request.email()); + }); + String certificationCode = CertificateNumberGenerator.generate(); + mailService.sendMail(request.email(), new OwnerRegistrationData(certificationCode)); + OwnerInVerification ownerInVerification = OwnerInVerification.of( + request.email(), + certificationCode + ); + ownerInVerificationRedisRepository.save(ownerInVerification); + emailVerifyRequestRedisRepository.save(new EmailVerifyRequest(request.email())); + eventPublisher.publishEvent(new OwnerEmailRequestEvent(ownerInVerification.getKey())); + } + + public OwnerResponse getOwner(Integer ownerId) { + Owner foundOwner = ownerRepository.getById(ownerId); + List shops = shopRepository.findAllByOwnerId(ownerId); + return OwnerResponse.of(foundOwner, foundOwner.getAttachments(), shops); + } + + @Transactional + public void register(OwnerRegisterRequest request) { + if (userRepository.findByEmail(request.email()).isPresent()) { + throw DuplicationEmailException.withDetail("email: " + request.email()); + } + if (ownerRepository.findByCompanyRegistrationNumber(request.companyNumber()).isPresent()) { + throw DuplicationCompanyNumberException.withDetail("companyNumber: " + request.companyNumber()); + } + Owner owner = request.toOwner(passwordEncoder); + Owner saved = ownerRepository.save(owner); + if (request.shopId() != null) { + var shop = shopRepository.getById(request.shopId()); + ownerShopRedisRepository.save(OwnerShop.builder() + .ownerId(owner.getId()) + .shopId(shop.getId()) + .build()); + } else { + ownerShopRedisRepository.save(OwnerShop.builder() + .ownerId(owner.getId()) + .build()); + } + + eventPublisher.publishEvent(new OwnerRegisterEvent(saved)); + } + + public OwnerVerifyResponse verifyCode(OwnerVerifyRequest request) { + var verify = ownerInVerificationRedisRepository.getByVerify(request.email()); + if (!Objects.equals(verify.getCertificationCode(), request.certificationCode())) { + throw new KoinIllegalArgumentException("인증번호가 일치하지 않습니다."); + } + ownerInVerificationRedisRepository.deleteById(request.email()); + String token = jwtProvider.createTemporaryToken(); + return new OwnerVerifyResponse(token); + } + + @Transactional + public void sendResetPasswordEmail(OwnerSendEmailRequest request) { + String certificationCode = CertificateNumberGenerator.generate(); + var verification = OwnerInVerification.of(request.email(), certificationCode); + ownerInVerificationRedisRepository.save(verification); + mailService.sendMail(request.email(), new OwnerPasswordChangeData(request.email(), certificationCode, clock)); + } + + @Transactional + public void verifyResetPasswordCode(OwnerPasswordResetVerifyRequest request) { + var verification = ownerInVerificationRedisRepository.getByVerify(request.email()); + if (!Objects.equals(verification.getCertificationCode(), request.certificationCode())) { + throw new KoinIllegalArgumentException("인증번호가 일치하지 않습니다."); + } + if (verification.isAuthed()) { + throw new DuplicationCertificationException("이미 인증이 완료되었습니다."); + } + verification.verify(); + ownerInVerificationRedisRepository.save(verification); + } + + @Transactional + public void updatePassword(OwnerPasswordUpdateRequest request) { + var verification = ownerInVerificationRedisRepository.getByVerify(request.email()); + if (!verification.isAuthed()) { + throw new KoinIllegalArgumentException("인증이 완료되지 않았습니다."); + } + User user = userRepository.getByEmail(request.email()); + user.updatePassword(passwordEncoder, request.password()); + userRepository.save(user); + ownerInVerificationRedisRepository.deleteById(verification.getKey()); + } + + @Transactional + public void requestSignUpPhoneVerification(@Valid VerifyPhoneRequest verifyPhoneRequest) { + + emailVerifyRequestRedisRepository.findById(verifyPhoneRequest.phoneNumber()).ifPresent(it -> { + throw new RequestTooFastException("요청이 너무 빠릅니다. %d초 뒤에 다시 시도해주세요".formatted(it.getExpiration())); + }); + userRepository.findByPhoneNumber(verifyPhoneRequest.phoneNumber()).ifPresent(user -> { + throw DuplicationEmailException.withDetail("phone: " + verifyPhoneRequest.phoneNumber()); + }); + String certificationCode = CertificateNumberGenerator.generate(); + + ownerInVerificationRedisRepository.save( + OwnerInVerification.of(verifyPhoneRequest.phoneNumber(), certificationCode)); + + naverSmsService.sendVerificationCode(certificationCode, verifyPhoneRequest.phoneNumber()); + eventPublisher.publishEvent(new OwnerPhoneRequestEvent(verifyPhoneRequest.phoneNumber())); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/EventArticleCreateShopEvent.java b/src/main/java/in/koreatech/koin/domain/ownershop/EventArticleCreateShopEvent.java new file mode 100644 index 000000000..80261f83c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/EventArticleCreateShopEvent.java @@ -0,0 +1,9 @@ +package in.koreatech.koin.domain.ownershop; + +public record EventArticleCreateShopEvent( + Integer shopId, + String shopName, + String title +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopApi.java b/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopApi.java new file mode 100644 index 000000000..c5e1ec621 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopApi.java @@ -0,0 +1,322 @@ +package in.koreatech.koin.domain.ownershop.controller; + +import static in.koreatech.koin.domain.user.model.UserType.OWNER; + +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.ownershop.dto.CreateEventRequest; +import in.koreatech.koin.domain.ownershop.dto.ModifyEventRequest; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopEventsResponse; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopsRequest; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopsResponse; +import in.koreatech.koin.domain.shop.dto.CreateCategoryRequest; +import in.koreatech.koin.domain.shop.dto.CreateMenuRequest; +import in.koreatech.koin.domain.shop.dto.MenuCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.MenuDetailResponse; +import in.koreatech.koin.domain.shop.dto.ModifyCategoryRequest; +import in.koreatech.koin.domain.shop.dto.ModifyMenuRequest; +import in.koreatech.koin.domain.shop.dto.ModifyShopRequest; +import in.koreatech.koin.domain.shop.dto.ShopMenuResponse; +import in.koreatech.koin.domain.shop.dto.ShopResponse; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Owner Shop: 상점 (점주 전용)", description = "사장님이 상점 정보를 관리한다.") +public interface OwnerShopApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "자신의 모든 상점 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/owner/shops") + ResponseEntity getOwnerShops( + @Auth(permit = {OWNER}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 생성") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/owner/shops") + ResponseEntity createOwnerShops( + @Auth(permit = {OWNER}) Integer userId, + @RequestBody @Valid OwnerShopsRequest ownerShopsRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/owner/shops/{id}") + ResponseEntity getOwnerShopByShopId( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable Integer id + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 메뉴 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/owner/shops/menus/{menuId}") + ResponseEntity getMenuByMenuId( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("menuId") Integer id + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 모든 메뉴 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/owner/shops/menus") + ResponseEntity getMenus( + @Auth(permit = {OWNER}) Integer ownerId, + @RequestParam("shopId") Integer shopId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 모든 메뉴 카테고리 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/owner/shops/menus/categories") + ResponseEntity getCategories( + @Auth(permit = {OWNER}) Integer ownerId, + @RequestParam("shopId") Integer shopId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 메뉴 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/owner/shops/menus/{menuId}") + ResponseEntity deleteMenuByMenuId( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("menuId") Integer menuId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 메뉴 카테고리 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/owner/shops/menus/categories/{categoryId}") + ResponseEntity deleteCategory( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("categoryId") Integer categoryId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "422", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 메뉴 생성") + @PostMapping("/owner/shops/{id}/menus") + ResponseEntity createMenu( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("id") Integer shopId, + @RequestBody @Valid CreateMenuRequest createMenuRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "422", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 메뉴 카테고리 생성") + @PostMapping("/owner/shops/{id}/menus/categories") + ResponseEntity createMenuCategory( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("id") Integer shopId, + @RequestBody @Valid CreateCategoryRequest createCategoryRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "422", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 메뉴 수정") + @PutMapping("/owner/shops/menus/{menuId}") + ResponseEntity modifyMenu( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("menuId") Integer menuId, + @RequestBody @Valid ModifyMenuRequest modifyMenuRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "422", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 메뉴 카테고리 수정") + @PutMapping("/owner/shops/menus/categories/{categoryId}") + ResponseEntity modifyMenuCategory( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("categoryId") Integer categoryId, + @RequestBody @Valid ModifyCategoryRequest modifyCategoryRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "422", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 수정") + @PutMapping("/owner/shops/{id}") + ResponseEntity modifyOwnerShop( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("id") Integer shopId, + @RequestBody @Valid ModifyShopRequest modifyShopRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "422", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 이벤트 추가") + @PostMapping("/owner/shops/{shopId}/events") + ResponseEntity createShopEvent( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("shopId") Integer shopId, + @RequestBody @Valid CreateEventRequest shopEventRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "422", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 이벤트 수정") + @PutMapping("/owner/shops/{shopId}/events/{eventId}") + ResponseEntity modifyShopEvent( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("shopId") Integer shopId, + @PathVariable("eventId") Integer eventId, + @RequestBody @Valid ModifyEventRequest modifyEventRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 이벤트 삭제") + @DeleteMapping("/owner/shops/{shopId}/events/{eventId}") + ResponseEntity deleteShopEvent( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("shopId") Integer shopId, + @PathVariable("eventId") Integer eventId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "422", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점 모든 이벤트 조회") + @GetMapping("/owner/shops/{shopId}/event") + ResponseEntity getShopAllEvent( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("shopId") Integer shopId + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java b/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java new file mode 100644 index 000000000..dcbe1696e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java @@ -0,0 +1,201 @@ +package in.koreatech.koin.domain.ownershop.controller; + +import static in.koreatech.koin.domain.user.model.UserType.OWNER; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.ownershop.dto.CreateEventRequest; +import in.koreatech.koin.domain.ownershop.dto.ModifyEventRequest; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopEventsResponse; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopsRequest; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopsResponse; +import in.koreatech.koin.domain.ownershop.service.OwnerShopService; +import in.koreatech.koin.domain.shop.dto.CreateCategoryRequest; +import in.koreatech.koin.domain.shop.dto.CreateMenuRequest; +import in.koreatech.koin.domain.shop.dto.MenuCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.MenuDetailResponse; +import in.koreatech.koin.domain.shop.dto.ModifyCategoryRequest; +import in.koreatech.koin.domain.shop.dto.ModifyMenuRequest; +import in.koreatech.koin.domain.shop.dto.ModifyShopRequest; +import in.koreatech.koin.domain.shop.dto.ShopMenuResponse; +import in.koreatech.koin.domain.shop.dto.ShopResponse; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +public class OwnerShopController implements OwnerShopApi { + + private final OwnerShopService ownerShopService; + + @GetMapping("/owner/shops") + public ResponseEntity getOwnerShops( + @Auth(permit = {OWNER}) Integer ownerId + ) { + OwnerShopsResponse ownerShopsResponses = ownerShopService.getOwnerShops(ownerId); + return ResponseEntity.ok().body(ownerShopsResponses); + } + + @PostMapping("/owner/shops") + public ResponseEntity createOwnerShops( + @Auth(permit = {OWNER}) Integer ownerId, + @RequestBody @Valid OwnerShopsRequest ownerShopsRequest + ) { + ownerShopService.createOwnerShops(ownerId, ownerShopsRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @GetMapping("/owner/shops/{id}") + public ResponseEntity getOwnerShopByShopId( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable Integer id + ) { + ShopResponse shopResponse = ownerShopService.getShopByShopId(ownerId, id); + return ResponseEntity.ok(shopResponse); + } + + @GetMapping("/owner/shops/menus/{menuId}") + public ResponseEntity getMenuByMenuId( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable Integer menuId + ) { + MenuDetailResponse menuDetailResponse = ownerShopService.getMenuByMenuId(ownerId, menuId); + return ResponseEntity.ok(menuDetailResponse); + } + + @GetMapping("/owner/shops/menus") + public ResponseEntity getMenus( + @Auth(permit = {OWNER}) Integer ownerId, + @RequestParam("shopId") Integer shopId + ) { + ShopMenuResponse shopMenuResponse = ownerShopService.getMenus(shopId, ownerId); + return ResponseEntity.ok(shopMenuResponse); + } + + @GetMapping("/owner/shops/menus/categories") + public ResponseEntity getCategories( + @Auth(permit = {OWNER}) Integer ownerId, + @RequestParam("shopId") Integer shopId + ) { + MenuCategoriesResponse menuCategoriesResponse = ownerShopService.getCategories(shopId, ownerId); + return ResponseEntity.ok(menuCategoriesResponse); + } + + @DeleteMapping("/owner/shops/menus/{menuId}") + public ResponseEntity deleteMenuByMenuId( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("menuId") Integer menuId + ) { + ownerShopService.deleteMenuByMenuId(ownerId, menuId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @DeleteMapping("/owner/shops/menus/categories/{categoryId}") + public ResponseEntity deleteCategory( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("categoryId") Integer categoryId + ) { + ownerShopService.deleteCategory(ownerId, categoryId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PostMapping("/owner/shops/{id}/menus") + public ResponseEntity createMenu( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("id") Integer shopId, + @RequestBody @Valid CreateMenuRequest createMenuRequest + ) { + ownerShopService.createMenu(shopId, ownerId, createMenuRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/owner/shops/{id}/menus/categories") + public ResponseEntity createMenuCategory( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("id") Integer shopId, + @RequestBody @Valid CreateCategoryRequest createCategoryRequest + ) { + ownerShopService.createMenuCategory(shopId, ownerId, createCategoryRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/owner/shops/menus/{menuId}") + public ResponseEntity modifyMenu( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("menuId") Integer menuId, + @RequestBody @Valid ModifyMenuRequest modifyMenuRequest + ) { + ownerShopService.modifyMenu(ownerId, menuId, modifyMenuRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/owner/shops/menus/categories/{categoryId}") + public ResponseEntity modifyMenuCategory( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("categoryId") Integer categoryId, + @RequestBody @Valid ModifyCategoryRequest modifyCategoryRequest + ) { + ownerShopService.modifyCategory(ownerId, categoryId, modifyCategoryRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/owner/shops/{id}") + public ResponseEntity modifyOwnerShop( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("id") Integer shopId, + @RequestBody @Valid ModifyShopRequest modifyShopRequest + ) { + ownerShopService.modifyShop(ownerId, shopId, modifyShopRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/owner/shops/{id}/event") + public ResponseEntity createShopEvent( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("id") Integer shopId, + @RequestBody @Valid CreateEventRequest shopEventRequest + ) { + ownerShopService.createEvent(ownerId, shopId, shopEventRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/owner/shops/{shopId}/events/{eventId}") + public ResponseEntity modifyShopEvent( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("shopId") Integer shopId, + @PathVariable("eventId") Integer eventId, + @RequestBody @Valid ModifyEventRequest modifyEventRequest + ) { + ownerShopService.modifyEvent(ownerId, shopId, eventId, modifyEventRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/owner/shops/{shopId}/events/{eventId}") + public ResponseEntity deleteShopEvent( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("shopId") Integer shopId, + @PathVariable("eventId") Integer eventId + ) { + ownerShopService.deleteEvent(ownerId, shopId, eventId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @GetMapping("/owner/shops/{shopId}/event") + public ResponseEntity getShopAllEvent( + @Auth(permit = {OWNER}) Integer ownerId, + @PathVariable("shopId") Integer shopId + ) { + OwnerShopEventsResponse shopEventsResponse = ownerShopService.getShopEvent(shopId, ownerId); + return ResponseEntity.ok(shopEventsResponse); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/dto/CreateEventRequest.java b/src/main/java/in/koreatech/koin/domain/ownershop/dto/CreateEventRequest.java new file mode 100644 index 000000000..be5fb62ee --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/dto/CreateEventRequest.java @@ -0,0 +1,46 @@ +package in.koreatech.koin.domain.ownershop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.format.annotation.DateTimeFormat; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record CreateEventRequest( + @Schema(example = "감성떡볶이 이벤트합니다!!!", description = "제목", requiredMode = REQUIRED) + @NotBlank(message = "제목은 필수입니다.") + String title, + + @Schema(example = "감성떡볶이 이벤트합니다!!! 많은관심 부탁드려요! 감성을 한스푼 더 얹어드립니다", description = "이벤트 내용", requiredMode = REQUIRED) + @NotBlank(message = "내용은 필수입니다.") + String content, + + @Schema(description = "이벤트 이미지", example = """ + [ "https://testimage.com", "https://testimage2.com" ] + """, requiredMode = REQUIRED) + @NotNull(message = "이벤트 이미지는 필수입니다.") + @Size(min = 0, max = 3, message = "사진은 최대 3개까지 입력 가능합니다.") + List thumbnailImages, + + @DateTimeFormat(pattern = "yyyy-MM-dd") + @Schema(example = "2024-10-24", description = "시작일", requiredMode = REQUIRED) + @NotNull(message = "시작일은 필수입니다.") + LocalDate startDate, + + @DateTimeFormat(pattern = "yyyy-MM-dd") + @Schema(example = "2024-11-24", description = "종료일", requiredMode = REQUIRED) + @NotNull(message = "종료일은 필수입니다.") + LocalDate endDate +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/dto/ModifyEventRequest.java b/src/main/java/in/koreatech/koin/domain/ownershop/dto/ModifyEventRequest.java new file mode 100644 index 000000000..025fef34f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/dto/ModifyEventRequest.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.domain.ownershop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ModifyEventRequest( + @Schema(example = "감성떡볶이 이벤트합니다!!!", description = "제목", requiredMode = REQUIRED) + @NotBlank(message = "제목은 필수입니다.") + String title, + + @Schema(example = "감성떡볶이 이벤트합니다!!! 많은관심 부탁드려요! 감성을 한스푼 더 얹어드립니다", description = "이벤트 내용", requiredMode = REQUIRED) + @NotBlank(message = "내용은 필수입니다.") + String content, + + @Schema(description = "이벤트 이미지", requiredMode = REQUIRED) + @NotNull(message = "이벤트 이미지는 필수입니다.") + @Size(min = 0, max = 3, message = "사진은 최대 3개까지 입력 가능합니다.") + List thumbnailImages, + + @Schema(example = "2024-10-24", description = "시작일", requiredMode = REQUIRED) + @NotNull(message = "시작일은 필수입니다.") + LocalDate startDate, + + @Schema(example = "2024-11-24", description = "종료일", requiredMode = REQUIRED) + @NotNull(message = "종료일은 필수입니다.") + LocalDate endDate +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopEventsResponse.java b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopEventsResponse.java new file mode 100644 index 000000000..3f8952548 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopEventsResponse.java @@ -0,0 +1,87 @@ +package in.koreatech.koin.domain.ownershop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.EventArticle; +import in.koreatech.koin.domain.shop.model.EventArticleImage; +import in.koreatech.koin.domain.shop.model.Shop; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record OwnerShopEventsResponse( + + @Schema(description = "이벤트 목록", requiredMode = NOT_REQUIRED) + List events +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerOwnerShopEventResponse( + @Schema(description = "상점 ID", example = "1", requiredMode = REQUIRED) + Integer shopId, + + @Schema(description = "상점 이름", example = "술꾼", requiredMode = REQUIRED) + String shopName, + + @Schema(description = "이벤트 ID", example = "1", requiredMode = REQUIRED) + Integer eventId, + + @Schema(description = "이벤트 제목", example = "콩순이 사장님이 미쳤어요!!", requiredMode = REQUIRED) + String title, + + @Schema(description = "이벤트 내용", example = "콩순이 가게 전메뉴 90% 할인! 가게 폐업 임박...", requiredMode = REQUIRED) + String content, + + @Schema(description = "이벤트 이미지") + List thumbnailImages, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(description = "시작일", example = "2024-10-22", requiredMode = REQUIRED) + LocalDate startDate, + + @Schema(description = "종료일", example = "2024-10-25", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate endDate + ) { + + public static InnerOwnerShopEventResponse from(EventArticle eventArticle) { + return new InnerOwnerShopEventResponse( + eventArticle.getShop().getId(), + eventArticle.getShop().getName(), + eventArticle.getId(), + eventArticle.getTitle(), + eventArticle.getContent(), + eventArticle.getThumbnailImages() + .stream().map(EventArticleImage::getThumbnailImage) + .toList(), + eventArticle.getStartDate(), + eventArticle.getEndDate() + ); + } + } + + public static OwnerShopEventsResponse from(List shops) { + List innerShopEventResponses = new ArrayList<>(); + for (Shop shop : shops) { + shop.getEventArticles().stream() + .map(InnerOwnerShopEventResponse::from) + .forEach(innerShopEventResponses::add); + } + return new OwnerShopEventsResponse(innerShopEventResponses); + } + + public static OwnerShopEventsResponse from(Shop shop) { + var innerShopEventResponses = shop.getEventArticles().stream() + .map(InnerOwnerShopEventResponse::from) + .toList(); + return new OwnerShopEventsResponse(innerShopEventResponses); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java new file mode 100644 index 000000000..b62f209e2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java @@ -0,0 +1,111 @@ +package in.koreatech.koin.domain.ownershop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalTime; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record OwnerShopsRequest( + @Schema(description = "주소", example = "충청남도 천안시 동남구 병천면 충절로 1600", requiredMode = REQUIRED) + @NotBlank(message = "주소를 입력해주세요.") + String address, + + @Schema(description = "상점 카테고리 고유 id 리스트", example = "[1]", requiredMode = REQUIRED) + @NotNull(message = "카테고리를 입력해주세요.") + @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + List categoryIds, + + @Schema(description = "배달 가능 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "배달 가능 여부를 입력해주세요.") + Boolean delivery, + + @Schema(description = "배달 금액", example = "1000", requiredMode = REQUIRED) + @NotNull(message = "배달 금액을 입력해주세요.") + @PositiveOrZero(message = "배달비는 0원 이상이어야 합니다.") + Integer deliveryPrice, + + @Schema(description = "기타정보", example = "이번주 전 메뉴 10% 할인 이벤트합니다.", requiredMode = REQUIRED) + @NotNull(message = "상점 설명은 null일 수 없습니다.") + String description, + + @Schema(description = "이미지 URL 리스트", example = """ + [ "https://testimage.com" ] + """, requiredMode = REQUIRED) + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(description = "가게명", example = "써니 숯불 도시락", requiredMode = REQUIRED) + @NotBlank(message = "상점 이름을 입력해주세요.") + String name, + + @Schema(description = "요일별 운영 시간과 휴무 여부", requiredMode = REQUIRED) + List open, + + @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "계좌 이체 가능 여부를 입력해주세요.") + Boolean payBank, + + @Schema(description = "카드 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "카드 가능 여부를 입력해주세요.") + Boolean payCard, + + @Schema(description = "전화번호", example = "041-123-4567", requiredMode = REQUIRED) + @NotBlank(message = "전화번호를 입력해주세요.") + @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}", message = "전화번호 형식이 올바르지 않습니다.") + String phone +) { + + public Shop toEntity(Owner owner) { + return Shop.builder() + .owner(owner) + .address(address) + .deliveryPrice(deliveryPrice) + .delivery(delivery) + .description(description) + .payBank(payBank) + .payCard(payCard) + .phone(phone) + .name(name) + .internalName(name) + .chosung(name.substring(0, 1)) + .isDeleted(false) + .isEvent(false) + .remarks("") + .hit(0) + .build(); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerOpenRequest( + @Schema(description = "닫는 시간", example = "22:30", requiredMode = REQUIRED) + LocalTime closeTime, + + @Schema(description = "휴무 여부", example = "false", requiredMode = REQUIRED) + Boolean closed, + + @Schema(description = "요일", example = "MONDAY", requiredMode = REQUIRED) + @NotBlank(message = "영업 요일을 입력해주세요.") + String dayOfWeek, + + @Schema(description = "여는 시간", example = "10:00", requiredMode = REQUIRED) + LocalTime openTime + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsResponse.java b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsResponse.java new file mode 100644 index 000000000..32f90c0b2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsResponse.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.domain.ownershop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record OwnerShopsResponse( + @Schema(description = "매장 수", example = "3", requiredMode = REQUIRED) + Integer count, + + @Schema(description = "매장 목록") + List shops +) { + + public static OwnerShopsResponse from(List shops) { + return new OwnerShopsResponse(shops.size(), shops); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopResponse( + @Schema(description = "매장 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "매장 이름", example = "감성떡볶이", requiredMode = REQUIRED) + String name, + + @Schema(description = "이벤트 진행 여부", example = "true", requiredMode = REQUIRED) + boolean isEvent + ) { + + public static InnerShopResponse from(Shop shop, boolean isEvent) { + return new InnerShopResponse(shop.getId(), shop.getName(), isEvent); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/exception/EventArticleNotFoundException.java b/src/main/java/in/koreatech/koin/domain/ownershop/exception/EventArticleNotFoundException.java new file mode 100644 index 000000000..743530e25 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/exception/EventArticleNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.ownershop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class EventArticleNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 이벤트 입니다."; + + public EventArticleNotFoundException(String message) { + super(message); + } + + public EventArticleNotFoundException(String message, String detail) { + super(message, detail); + } + + public static EventArticleNotFoundException withDetail(String detail) { + return new EventArticleNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java b/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java new file mode 100644 index 000000000..2172a7ab1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java @@ -0,0 +1,324 @@ +package in.koreatech.koin.domain.ownershop.service; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.owner.repository.OwnerRepository; +import in.koreatech.koin.domain.ownershop.EventArticleCreateShopEvent; +import in.koreatech.koin.domain.ownershop.dto.CreateEventRequest; +import in.koreatech.koin.domain.ownershop.dto.ModifyEventRequest; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopEventsResponse; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopsRequest; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopsResponse; +import in.koreatech.koin.domain.ownershop.dto.OwnerShopsResponse.InnerShopResponse; +import in.koreatech.koin.domain.shop.dto.CreateCategoryRequest; +import in.koreatech.koin.domain.shop.dto.CreateMenuRequest; +import in.koreatech.koin.domain.shop.dto.MenuCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.MenuDetailResponse; +import in.koreatech.koin.domain.shop.dto.ModifyCategoryRequest; +import in.koreatech.koin.domain.shop.dto.ModifyMenuRequest; +import in.koreatech.koin.domain.shop.dto.ModifyShopRequest; +import in.koreatech.koin.domain.shop.dto.ShopMenuResponse; +import in.koreatech.koin.domain.shop.dto.ShopResponse; +import in.koreatech.koin.domain.shop.model.EventArticle; +import in.koreatech.koin.domain.shop.model.EventArticleImage; +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.domain.shop.repository.EventArticleRepository; +import in.koreatech.koin.domain.shop.repository.MenuCategoryMapRepository; +import in.koreatech.koin.domain.shop.repository.MenuCategoryRepository; +import in.koreatech.koin.domain.shop.repository.MenuDetailRepository; +import in.koreatech.koin.domain.shop.repository.MenuImageRepository; +import in.koreatech.koin.domain.shop.repository.MenuRepository; +import in.koreatech.koin.domain.shop.repository.ShopCategoryMapRepository; +import in.koreatech.koin.domain.shop.repository.ShopCategoryRepository; +import in.koreatech.koin.domain.shop.repository.ShopImageRepository; +import in.koreatech.koin.domain.shop.repository.ShopOpenRepository; +import in.koreatech.koin.domain.shop.repository.ShopRepository; +import in.koreatech.koin.global.auth.exception.AuthorizationException; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OwnerShopService { + + private final EntityManager entityManager; + private final Clock clock; + private final ShopRepository shopRepository; + private final OwnerRepository ownerRepository; + private final ShopOpenRepository shopOpenRepository; + private final ShopCategoryMapRepository shopCategoryMapRepository; + private final ShopCategoryRepository shopCategoryRepository; + private final ShopImageRepository shopImageRepository; + private final MenuRepository menuRepository; + private final MenuCategoryRepository menuCategoryRepository; + private final MenuCategoryMapRepository menuCategoryMapRepository; + private final MenuImageRepository menuImageRepository; + private final MenuDetailRepository menuDetailRepository; + private final EventArticleRepository eventArticleRepository; + private final ApplicationEventPublisher eventPublisher; + + public OwnerShopsResponse getOwnerShops(Integer ownerId) { + List shops = shopRepository.findAllByOwnerId(ownerId); + var innerShopResponses = shops.stream().map(shop -> { + boolean eventDuration = eventArticleRepository.isDurationEvent(shop.getId(), LocalDate.now(clock)); + return InnerShopResponse.from(shop, eventDuration); + }) + .toList(); + return OwnerShopsResponse.from(innerShopResponses); + } + + @Transactional + public void createOwnerShops(Integer ownerId, OwnerShopsRequest ownerShopsRequest) { + Owner owner = ownerRepository.getById(ownerId); + Shop newShop = ownerShopsRequest.toEntity(owner); + Shop savedShop = shopRepository.save(newShop); + List categoryNames = List.of("추천 메뉴", "메인 메뉴", "세트 메뉴", "사이드 메뉴"); + for (String categoryName : categoryNames) { + MenuCategory menuCategory = MenuCategory.builder() + .shop(savedShop) + .name(categoryName) + .build(); + menuCategoryRepository.save(menuCategory); + } + for (String imageUrl : ownerShopsRequest.imageUrls()) { + ShopImage shopImage = ShopImage.builder() + .shop(savedShop) + .imageUrl(imageUrl) + .build(); + shopImageRepository.save(shopImage); + } + for (OwnerShopsRequest.InnerOpenRequest open : ownerShopsRequest.open()) { + ShopOpen shopOpen = ShopOpen.builder() + .shop(savedShop) + .openTime(open.openTime()) + .closeTime(open.closeTime()) + .dayOfWeek(open.dayOfWeek()) + .closed(open.closed()) + .build(); + shopOpenRepository.save(shopOpen); + } + List shopCategories = shopCategoryRepository.findAllByIdIn(ownerShopsRequest.categoryIds()); + for (ShopCategory shopCategory : shopCategories) { + ShopCategoryMap shopCategoryMap = ShopCategoryMap.builder() + .shopCategory(shopCategory) + .shop(savedShop) + .build(); + shopCategoryMapRepository.save(shopCategoryMap); + } + } + + public ShopResponse getShopByShopId(Integer ownerId, Integer shopId) { + Shop shop = getOwnerShopById(shopId, ownerId); + boolean eventDuration = eventArticleRepository.isDurationEvent(shopId, LocalDate.now(clock)); + return ShopResponse.from(shop, eventDuration); + } + + private Shop getOwnerShopById(Integer shopId, Integer ownerId) { + Shop shop = shopRepository.getById(shopId); + if (!Objects.equals(shop.getOwner().getId(), ownerId)) { + throw AuthorizationException.withDetail("ownerId: " + ownerId); + } + return shop; + } + + public MenuDetailResponse getMenuByMenuId(Integer ownerId, Integer menuId) { + Menu menu = menuRepository.getById(menuId); + getOwnerShopById(menu.getShopId(), ownerId); + List menuCategories = menu.getMenuCategoryMaps() + .stream() + .map(MenuCategoryMap::getMenuCategory) + .toList(); + return MenuDetailResponse.createMenuDetailResponse(menu, menuCategories); + } + + public ShopMenuResponse getMenus(Integer shopId, Integer ownerId) { + Shop shop = getOwnerShopById(shopId, ownerId); + List

menus = menuRepository.findAllByShopId(shop.getId()); + return ShopMenuResponse.from(menus); + } + + public MenuCategoriesResponse getCategories(Integer shopId, Integer ownerId) { + Shop shop = getOwnerShopById(shopId, ownerId); + List menuCategories = menuCategoryRepository.findAllByShopId(shop.getId()); + return MenuCategoriesResponse.from(menuCategories); + } + + @Transactional + public void deleteMenuByMenuId(Integer ownerId, Integer menuId) { + Menu menu = menuRepository.getById(menuId); + getOwnerShopById(menu.getShopId(), ownerId); + menuRepository.deleteById(menuId); + } + + @Transactional + public void deleteCategory(Integer ownerId, Integer categoryId) { + MenuCategory menuCategory = menuCategoryRepository.getById(categoryId); + getOwnerShopById(menuCategory.getShop().getId(), ownerId); + menuCategoryRepository.deleteById(categoryId); + } + + @Transactional + public void createMenu(Integer shopId, Integer ownerId, CreateMenuRequest createMenuRequest) { + getOwnerShopById(shopId, ownerId); + Menu menu = createMenuRequest.toEntity(shopId); + Menu savedMenu = menuRepository.save(menu); + for (Integer categoryId : createMenuRequest.categoryIds()) { + MenuCategory menuCategory = menuCategoryRepository.getById(categoryId); + MenuCategoryMap menuCategoryMap = MenuCategoryMap.builder() + .menuCategory(menuCategory) + .menu(savedMenu) + .build(); + menuCategoryMapRepository.save(menuCategoryMap); + } + for (String imageUrl : createMenuRequest.imageUrls()) { + MenuImage menuImage = MenuImage.builder() + .imageUrl(imageUrl) + .menu(savedMenu) + .build(); + menuImageRepository.save(menuImage); + } + if (createMenuRequest.optionPrices() == null) { + MenuOption menuOption = MenuOption.builder() + .option(savedMenu.getName()) + .price(createMenuRequest.singlePrice()) + .menu(menu) + .build(); + menuDetailRepository.save(menuOption); + } else { + for (var option : createMenuRequest.optionPrices()) { + MenuOption menuOption = MenuOption.builder() + .option(option.option()) + .price(option.price()) + .menu(menu) + .build(); + menuDetailRepository.save(menuOption); + } + } + } + + @Transactional + public void createMenuCategory(Integer shopId, Integer ownerId, CreateCategoryRequest createCategoryRequest) { + Shop shop = getOwnerShopById(shopId, ownerId); + MenuCategory menuCategory = MenuCategory.builder() + .shop(shop) + .name(createCategoryRequest.name()) + .build(); + menuCategoryRepository.save(menuCategory); + } + + @Transactional + public void modifyMenu(Integer ownerId, Integer menuId, ModifyMenuRequest modifyMenuRequest) { + Menu menu = menuRepository.getById(menuId); + getOwnerShopById(menu.getShopId(), ownerId); + menu.modifyMenu( + modifyMenuRequest.name(), + modifyMenuRequest.description() + ); + menu.modifyMenuImages(modifyMenuRequest.imageUrls(), entityManager); + menu.modifyMenuCategories(menuCategoryRepository.findAllByIdIn(modifyMenuRequest.categoryIds()), entityManager); + if (modifyMenuRequest.isSingle()) { + menu.modifyMenuSingleOptions(modifyMenuRequest, entityManager); + } else { + menu.modifyMenuMultipleOptions(modifyMenuRequest.optionPrices(), entityManager); + } + } + + @Transactional + public void modifyCategory(Integer ownerId, Integer categoryId, ModifyCategoryRequest modifyCategoryRequest) { + MenuCategory menuCategory = menuCategoryRepository.getById(categoryId); + getOwnerShopById(menuCategory.getShop().getId(), ownerId); + menuCategory.modifyName(modifyCategoryRequest.name()); + } + + @Transactional + public void modifyShop(Integer ownerId, Integer shopId, ModifyShopRequest modifyShopRequest) { + Shop shop = getOwnerShopById(shopId, ownerId); + shop.modifyShop( + modifyShopRequest.name(), + modifyShopRequest.phone(), + modifyShopRequest.address(), + modifyShopRequest.description(), + modifyShopRequest.delivery(), + modifyShopRequest.deliveryPrice(), + modifyShopRequest.payCard(), + modifyShopRequest.payBank() + ); + shop.modifyShopImages(modifyShopRequest.imageUrls(), entityManager); + shop.modifyShopOpens(modifyShopRequest.open(), entityManager); + shop.modifyShopCategories(shopCategoryRepository.findAllByIdIn(modifyShopRequest.categoryIds()), entityManager); + shopRepository.save(shop); + } + + @Transactional + public void createEvent(Integer ownerId, Integer shopId, CreateEventRequest shopEventRequest) { + Shop shop = getOwnerShopById(shopId, ownerId); + EventArticle eventArticle = EventArticle.builder() + .shop(shop) + .startDate(shopEventRequest.startDate()) + .endDate(shopEventRequest.endDate()) + .title(shopEventRequest.title()) + .content(shopEventRequest.content()) + .user(shop.getOwner().getUser()) + .hit(0) + .ip("") + .build(); + EventArticle savedEventArticle = eventArticleRepository.save(eventArticle); + for (String image : shopEventRequest.thumbnailImages()) { + savedEventArticle.getThumbnailImages() + .add(EventArticleImage.builder() + .eventArticle(eventArticle) + .thumbnailImage(image) + .build()); + } + eventPublisher.publishEvent( + new EventArticleCreateShopEvent( + shop.getId(), + shop.getName(), + savedEventArticle.getTitle() + ) + ); + } + + @Transactional + public void modifyEvent(Integer ownerId, Integer shopId, Integer eventId, ModifyEventRequest modifyEventRequest) { + getOwnerShopById(shopId, ownerId); + EventArticle eventArticle = eventArticleRepository.getById(eventId); + eventArticle.modifyArticle( + modifyEventRequest.title(), + modifyEventRequest.content(), + modifyEventRequest.thumbnailImages(), + modifyEventRequest.startDate(), + modifyEventRequest.endDate(), + entityManager + ); + } + + @Transactional + public void deleteEvent(Integer ownerId, Integer shopId, Integer eventId) { + getOwnerShopById(shopId, ownerId); + eventArticleRepository.deleteById(eventId); + } + + public OwnerShopEventsResponse getShopEvent(Integer shopId, Integer ownerId) { + Shop shop = getOwnerShopById(shopId, ownerId); + return OwnerShopEventsResponse.from(shop); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java new file mode 100644 index 000000000..38ca5961e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java @@ -0,0 +1,129 @@ +package in.koreatech.koin.domain.shop.controller; + +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import in.koreatech.koin.domain.shop.dto.MenuCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.MenuDetailResponse; +import in.koreatech.koin.domain.shop.dto.ShopCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.ShopEventsResponse; +import in.koreatech.koin.domain.shop.dto.ShopMenuResponse; +import in.koreatech.koin.domain.shop.dto.ShopResponse; +import in.koreatech.koin.domain.shop.dto.ShopsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Shop: 상점", description = "상점 정보를 관리한다") +public interface ShopApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "메뉴 단건 조회") + @GetMapping("/shops/{shopId}/menus/{menuId}") + ResponseEntity findMenu( + @Parameter(in = PATH) @PathVariable Integer shopId, + @Parameter(in = PATH) @PathVariable Integer menuId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 모든 메뉴 조회") + @GetMapping("/shops/{id}/menus") + ResponseEntity findMenus( + @Parameter(in = PATH) @PathVariable Integer id + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "메뉴 카테고리 목록 조회") + @GetMapping("/shops/{shopId}/menus/categories") + ResponseEntity getMenuCategories( + @Parameter(in = PATH) @PathVariable Integer shopId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점 조회") + @GetMapping("/shops/{id}") + ResponseEntity getShopById( + @Parameter(in = PATH) @PathVariable Integer id + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "모든 상점 조회") + @GetMapping("/shops") + ResponseEntity getShops(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "모든 상점 카테고리 조회") + @GetMapping("/shops/categories") + ResponseEntity getShopsCategories(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 모든 이벤트 조회") + @GetMapping("/shops/{shopId}/events") + ResponseEntity getShopEvents( + @PathVariable Integer shopId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "모든 상점의 모든 이벤트 조회") + @GetMapping("/shops/events") + ResponseEntity getShopAllEvent(); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java new file mode 100644 index 000000000..3254f781d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java @@ -0,0 +1,82 @@ +package in.koreatech.koin.domain.shop.controller; + +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.RestController; + +import in.koreatech.koin.domain.shop.dto.MenuCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.MenuDetailResponse; +import in.koreatech.koin.domain.shop.dto.ShopCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.ShopEventsResponse; +import in.koreatech.koin.domain.shop.dto.ShopMenuResponse; +import in.koreatech.koin.domain.shop.dto.ShopResponse; +import in.koreatech.koin.domain.shop.dto.ShopsResponse; +import in.koreatech.koin.domain.shop.service.ShopService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class ShopController implements ShopApi { + + private final ShopService shopService; + + @GetMapping("/shops/{shopId}/menus/{menuId}") + public ResponseEntity findMenu( + @PathVariable Integer shopId, + @PathVariable Integer menuId + ) { + MenuDetailResponse shopMenu = shopService.findMenu(menuId); + return ResponseEntity.ok(shopMenu); + } + + @GetMapping("/shops/{id}/menus") + public ResponseEntity findMenus( + @PathVariable Integer id + ) { + ShopMenuResponse shopMenuResponse = shopService.getShopMenus(id); + return ResponseEntity.ok(shopMenuResponse); + } + + @GetMapping("/shops/{shopId}/menus/categories") + public ResponseEntity getMenuCategories( + @PathVariable Integer shopId + ) { + MenuCategoriesResponse menuCategories = shopService.getMenuCategories(shopId); + return ResponseEntity.ok(menuCategories); + } + + @GetMapping("/shops/{id}") + public ResponseEntity getShopById( + @PathVariable Integer id + ) { + ShopResponse shopResponse = shopService.getShop(id); + return ResponseEntity.ok(shopResponse); + } + + @GetMapping("/shops") + public ResponseEntity getShops() { + ShopsResponse shopsResponse = shopService.getShops(); + return ResponseEntity.ok(shopsResponse); + } + + @GetMapping("/shops/categories") + public ResponseEntity getShopsCategories() { + ShopCategoriesResponse shopCategoriesResponse = shopService.getShopsCategories(); + return ResponseEntity.ok(shopCategoriesResponse); + } + + @GetMapping("/shops/{shopId}/events") + public ResponseEntity getShopEvents( + @PathVariable Integer shopId + ) { + var response = shopService.getShopEvents(shopId); + return ResponseEntity.ok(response); + } + + @GetMapping("/shops/events") + public ResponseEntity getShopAllEvent() { + var response = shopService.getAllEvents(); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/CreateCategoryRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/CreateCategoryRequest.java new file mode 100644 index 000000000..ac855fc91 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/CreateCategoryRequest.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateCategoryRequest( + @Schema(example = "사이드 메뉴", description = "카테고리명", requiredMode = REQUIRED) + @NotBlank(message = "카테고리명은 필수입니다.") + @Size(min = 1, max = 20, message = "카테고리명은 1자 이상 20자 이하로 입력해주세요.") + String name +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java new file mode 100644 index 000000000..32b3439f1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java @@ -0,0 +1,73 @@ +package in.koreatech.koin.domain.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record CreateMenuRequest( + @Schema(example = "[1, 2, 3]", description = "선택된 카테고리 고유 id 리스트", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + List categoryIds, + + @Schema(example = "저희 가게의 대표 메뉴 짜장면입니다.", description = "메뉴 구성 설명", requiredMode = REQUIRED) + @Size(max = 80, message = "메뉴 구성 설명은 80자 이하로 입력해주세요.") + String description, + + @Schema(example = """ + [ "https://static.koreatech.in/example.png" ] + """, description = "이미지 URL 리스트", requiredMode = REQUIRED) + @Size(max = 3, message = "이미지는 최대 3개까지 입력 가능합니다.") + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(example = "true", description = "단일 메뉴 여부", requiredMode = REQUIRED) + @NotNull(message = "단일 메뉴 여부는 필수입니다.") + boolean isSingle, + + @Schema(example = "짜장면", description = "메뉴명") + @NotNull(message = "메뉴명은 필수입니다.") + @Size(min = 1, max = 25, message = "메뉴명은 1자 이상 25자 이하로 입력해주세요.") + String name, + + @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + Integer singlePrice +) { + + public Menu toEntity(Integer shopId) { + return Menu.builder() + .name(name) + .shopId(shopId) + .description(description) + .build(); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerOptionPrice( + @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) + @NotNull @Size(min = 1, max = 50) String option, + + @Schema(example = "26000", description = "가격", requiredMode = REQUIRED) + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + @NotNull Integer price + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/MenuCategoriesResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/MenuCategoriesResponse.java new file mode 100644 index 000000000..c2d0000e9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/MenuCategoriesResponse.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.domain.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.MenuCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record MenuCategoriesResponse( + @Schema(description = "카테고리 수", example = "3") + Long count, + + @Schema(description = "카테고리 목록") + List menuCategories +) { + + public static MenuCategoriesResponse from(List menuCategories) { + List categories = menuCategories.stream() + .map(menuCategory -> MenuCategoryResponse.of(menuCategory.getId(), menuCategory.getName())) + .toList(); + + return new MenuCategoriesResponse((long)categories.size(), categories); + } + + private record MenuCategoryResponse( + @Schema(description = "카테고리 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "카테고리 이름", example = "치킨", requiredMode = REQUIRED) + String name + ) { + + public static MenuCategoryResponse of(Integer id, String name) { + return new MenuCategoryResponse(id, name); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/MenuDetailResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/MenuDetailResponse.java new file mode 100644 index 000000000..55a9ade67 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/MenuDetailResponse.java @@ -0,0 +1,112 @@ +package in.koreatech.koin.domain.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.global.exception.KoinIllegalStateException; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@JsonNaming(value = SnakeCaseStrategy.class) +public record MenuDetailResponse( + @Schema(example = "1", description = "고유id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "1", description = "메뉴가 소속된 상점의 고유 id", requiredMode = REQUIRED) + Integer shopId, + + @Schema(example = "탕수육", description = "이름", requiredMode = REQUIRED) + String name, + + @Schema(example = "false", description = "숨김 여부", requiredMode = REQUIRED) + Boolean isHidden, + + @Schema(example = "false", description = "단일 메뉴 여부", requiredMode = REQUIRED) + Boolean isSingle, + + @Schema(example = "7000", description = "단일 메뉴일때(is_single이 true일때)의 가격", requiredMode = REQUIRED) + Integer singlePrice, + + @Schema(description = "옵션이 있는 메뉴일때(is_single이 false일때)의 가격", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(example = "돼지고기 + 튀김", description = "구성 설명", requiredMode = REQUIRED) + String description, + + @Schema(description = "소속되어 있는 메뉴 카테고리 고유 id 리스트", requiredMode = REQUIRED) + List categoryIds, + + @Schema(description = "이미지 URL 리스트", requiredMode = NOT_REQUIRED) + List imageUrls +) { + + public static MenuDetailResponse createForSingleOption(Menu menu, List shopMenuCategories) { + if (menu.hasMultipleOption()) { + log.warn("{}는 옵션이 하나 이상인 메뉴입니다. createForMultipleOption 메서드를 이용해야 합니다.", menu); + throw new KoinIllegalStateException("서버에 에러가 발생했습니다."); + } + + return new MenuDetailResponse( + menu.getId(), + menu.getShopId(), + menu.getName(), + menu.isHidden(), + true, + menu.getMenuOptions().get(0).getPrice(), + null, + menu.getDescription(), + shopMenuCategories.stream().map(MenuCategory::getId).toList(), + menu.getMenuImages().stream().map(MenuImage::getImageUrl).toList() + ); + } + + public static MenuDetailResponse createMenuDetailResponse(Menu menu, List menuCategories) { + if (menu.hasMultipleOption()) { + return MenuDetailResponse.createForMultipleOption(menu, menuCategories); + } + return MenuDetailResponse.createForSingleOption(menu, menuCategories); + } + + public static MenuDetailResponse createForMultipleOption(Menu menu, List shopMenuCategories) { + if (!menu.hasMultipleOption()) { + log.error("{}는 옵션이 하나인 메뉴입니다. createForSingleOption 메서드를 이용해야 합니다.", menu); + throw new KoinIllegalStateException("서버에 에러가 발생했습니다."); + } + + return new MenuDetailResponse( + menu.getId(), + menu.getShopId(), + menu.getName(), + menu.isHidden(), + false, + null, + menu.getMenuOptions().stream().map(InnerOptionPriceResponse::of).toList(), + menu.getDescription(), + shopMenuCategories.stream().map(MenuCategory::getId).toList(), + menu.getMenuImages().stream().map(MenuImage::getImageUrl).toList() + ); + } + + private record InnerOptionPriceResponse( + @Schema(example = "소", description = "옵션명", requiredMode = REQUIRED) + String option, + + @Schema(example = "10000", description = "옵션에 대한 가격", requiredMode = REQUIRED) + Integer price + ) { + + public static InnerOptionPriceResponse of(MenuOption menuOption) { + return new InnerOptionPriceResponse(menuOption.getOption(), menuOption.getPrice()); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyCategoryRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyCategoryRequest.java new file mode 100644 index 000000000..8218c2782 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyCategoryRequest.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record ModifyCategoryRequest( + @Schema(example = "1", description = "상점 카테고리 고유 id", requiredMode = REQUIRED) + @NotNull(message = "카테고리 ID는 필수입니다.") + Long id, + + @Schema(example = "사이드 메뉴", description = "카테고리 명", requiredMode = REQUIRED) + @NotNull(message = "카테고리 명은 필수입니다.") + String name +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java new file mode 100644 index 000000000..8fd6f5c9d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java @@ -0,0 +1,67 @@ +package in.koreatech.koin.domain.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ModifyMenuRequest( + @Schema(example = "[1, 2, 3]", description = "선택된 카테고리 고유 id 리스트", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + List categoryIds, + + @Schema(example = "저희 가게의 대표 메뉴 짜장면입니다.", description = "메뉴 구성 설명", requiredMode = REQUIRED) + @Size(max = 80, message = "메뉴 구성 설명은 80자 이하로 입력해주세요.") + String description, + + @Schema(example = """ + [ "https://static.koreatech.in/example.png" ] + """, description = "이미지 URL 리스트", requiredMode = NOT_REQUIRED) + @Size(max = 3, message = "이미지는 최대 3개까지 입력 가능합니다.") + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(example = "true", description = "단일 메뉴 여부", requiredMode = REQUIRED) + @NotNull(message = "단일 메뉴 여부는 필수입니다.") + boolean isSingle, + + @Schema(example = "짜장면", description = "메뉴명", requiredMode = REQUIRED) + @NotNull(message = "메뉴명은 필수입니다.") + @Size(min = 1, max = 25, message = "메뉴명은 1자 이상 25자 이하로 입력해주세요.") + String name, + + @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + Integer singlePrice +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerOptionPrice( + @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) + @NotNull(message = "옵션명은 필수입니다.") + @Size(min = 1, max = 50, message = "옵션명은 1자 이상 50자 이하로 입력해주세요.") + String option, + + @Schema(example = "26000", description = "가격", requiredMode = REQUIRED) + @NotNull(message = "가격은 필수입니다.") + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + Integer price + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java new file mode 100644 index 000000000..3c57af8f4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java @@ -0,0 +1,102 @@ +package in.koreatech.koin.domain.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ModifyShopRequest( + @Schema(example = "충청남도 천안시 동남구 병천면", description = "주소", requiredMode = NOT_REQUIRED) + String address, + + @Schema(example = "[1, 2]", description = "상점 카테고리 고유 id 리스트", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + List categoryIds, + + @Schema(example = "true", description = "배달 가능 여부", requiredMode = REQUIRED) + @NotNull(message = "배달 가능 여부는 필수입니다.") + Boolean delivery, + + @Schema(example = "1000", description = "배달비", requiredMode = REQUIRED) + @NotNull(message = "배달비는 필수입니다.") + @PositiveOrZero(message = "배달비는 0원 이상이어야 합니다.") + Integer deliveryPrice, + + @Schema(example = "string", description = "설명", requiredMode = NOT_REQUIRED) + @NotNull(message = "상점 설명은 null일 수 없습니다.") + String description, + + @Schema(description = "이미지 URL 리스트", example = """ + [ "https://static.koreatech.in/example.png" ] + """, requiredMode = NOT_REQUIRED) + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(example = "수신반점", description = "이름", requiredMode = REQUIRED) + @NotBlank(message = "이름은 필수입니다.") + String name, + + @Schema(description = "요일별 휴무 여부 및 장사 시간", requiredMode = NOT_REQUIRED) + List open, + + @Schema(example = "true", description = "계좌 이체 가능 여부", requiredMode = REQUIRED) + @NotNull(message = "계좌 이체 가능 여부는 필수입니다.") + Boolean payBank, + + @Schema(example = "false", description = "카드 계산 가능 여부", requiredMode = REQUIRED) + @NotNull(message = "카드 계산 가능 여부는 필수입니다.") + Boolean payCard, + + @Schema(example = "041-000-0000", description = "전화번호", requiredMode = NOT_REQUIRED) + @Size(max = 50, message = "전화번호는 50자 이하로 입력해주세요.") + String phone +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopOpen( + @Schema(example = "MONDAY", description = """ + 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] + """, requiredMode = REQUIRED) + String dayOfWeek, + + @Schema(example = "false", description = "휴무 여부", requiredMode = REQUIRED) + @NotNull(message = "휴무 여부는 필수입니다.") + boolean closed, + + @JsonFormat(pattern = "HH:mm") + @Schema(example = "02:00", description = "오픈 시간", requiredMode = NOT_REQUIRED) + LocalTime openTime, + + @JsonFormat(pattern = "HH:mm") + @Schema(example = "16:00", description = "마감 시간", requiredMode = NOT_REQUIRED) + LocalTime closeTime + ) { + + public ShopOpen toEntity(Shop shop) { + return ShopOpen.builder() + .shop(shop) + .closed(closed) + .openTime(openTime) + .closeTime(closeTime) + .dayOfWeek(dayOfWeek) + .build(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ShopCategoriesResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopCategoriesResponse.java new file mode 100644 index 000000000..09ff84589 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopCategoriesResponse.java @@ -0,0 +1,50 @@ +package in.koreatech.koin.domain.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.ShopCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ShopCategoriesResponse( + @Schema(example = "10", description = "상점 카테고리 개수", requiredMode = REQUIRED) + Integer totalCount, + + @Schema(description = "모든 상점 카테고리 리스트", requiredMode = NOT_REQUIRED) + List shopCategories +) { + + public static ShopCategoriesResponse from(List shopCategories) { + return new ShopCategoriesResponse( + shopCategories.size(), + shopCategories.stream().map(InnerShopCategory::from).toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerShopCategory( + @Schema(example = "2", description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "https://static.koreatech.in/test.png", description = "이미지 URL", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(example = "치킨", description = "이름", requiredMode = REQUIRED) + String name + ) { + + public static InnerShopCategory from(ShopCategory shopCategory) { + return new InnerShopCategory( + shopCategory.getId(), + shopCategory.getImageUrl(), + shopCategory.getName() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ShopEventsResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopEventsResponse.java new file mode 100644 index 000000000..ef8e38ff6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopEventsResponse.java @@ -0,0 +1,97 @@ +package in.koreatech.koin.domain.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.EventArticle; +import in.koreatech.koin.domain.shop.model.EventArticleImage; +import in.koreatech.koin.domain.shop.model.Shop; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ShopEventsResponse( + + @Schema(description = "이벤트 목록") + List events +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopEventResponse( + @Schema(description = "상점 ID", example = "1", requiredMode = REQUIRED) + Integer shopId, + + @Schema(description = "상점 이름", example = "술꾼", requiredMode = REQUIRED) + String shopName, + + @Schema(description = "이벤트 ID", example = "1", requiredMode = REQUIRED) + Integer eventId, + + @Schema(description = "이벤트 제목", example = "콩순이 사장님이 미쳤어요!!", requiredMode = REQUIRED) + String title, + + @Schema(description = "이벤트 내용", example = "콩순이 가게 전메뉴 90% 할인! 가게 폐업 임박...", requiredMode = REQUIRED) + String content, + + @Schema(description = "이벤트 이미지", example = """ + [ "https://static.koreatech.in/example.png" ] + """, requiredMode = NOT_REQUIRED) + List thumbnailImages, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(description = "시작일", example = "2024-10-22", requiredMode = REQUIRED) + LocalDate startDate, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(description = "종료일", example = "2024-10-25", requiredMode = REQUIRED) + LocalDate endDate + ) { + + public static InnerShopEventResponse from(EventArticle eventArticle) { + return new InnerShopEventResponse( + eventArticle.getShop().getId(), + eventArticle.getShop().getName(), + eventArticle.getId(), + eventArticle.getTitle(), + eventArticle.getContent(), + eventArticle.getThumbnailImages() + .stream().map(EventArticleImage::getThumbnailImage) + .toList(), + eventArticle.getStartDate(), + eventArticle.getEndDate() + ); + } + } + + public static ShopEventsResponse of(List shops, Clock clock) { + List innerShopEventResponses = new ArrayList<>(); + for (Shop shop : shops) { + for (EventArticle eventArticle : shop.getEventArticles()) { + if (!eventArticle.getStartDate().isAfter(LocalDate.now(clock)) && + !eventArticle.getEndDate().isBefore(LocalDate.now(clock))) { + innerShopEventResponses.add(InnerShopEventResponse.from(eventArticle)); + } + } + } + return new ShopEventsResponse(innerShopEventResponses); + } + + public static ShopEventsResponse of(Shop shop, Clock clock) { + List innerShopEventResponses = new ArrayList<>(); + for (EventArticle eventArticle : shop.getEventArticles()) { + if (!eventArticle.getStartDate().isAfter(LocalDate.now(clock)) && + !eventArticle.getEndDate().isBefore(LocalDate.now(clock))) { + innerShopEventResponses.add(InnerShopEventResponse.from(eventArticle)); + } + } + return new ShopEventsResponse(innerShopEventResponses); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ShopMenuResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopMenuResponse.java new file mode 100644 index 000000000..0ae82ae55 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopMenuResponse.java @@ -0,0 +1,160 @@ +package in.koreatech.koin.domain.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ShopMenuResponse( + @Schema(example = "20", description = "개수", requiredMode = REQUIRED) + Integer count, + + @Schema(description = "카테고리 별로 분류된 소속 메뉴 리스트") + List menuCategories, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(example = "2024-03-16", description = "해당 상점 마지막 메뉴 업데이트 날짜", requiredMode = REQUIRED) + LocalDateTime updatedAt +) { + + public static ShopMenuResponse from(List menus) { + LocalDateTime lastUpdatedAt = LocalDateTime.MIN; + List innerMenuCategoriesResponses = new ArrayList<>(); + for (Menu menu : menus) { + if (lastUpdatedAt.isBefore(menu.getUpdatedAt())) { + lastUpdatedAt = menu.getUpdatedAt(); + } + for (MenuCategoryMap menuCategoryMap : menu.getMenuCategoryMaps()) { + MenuCategory menuCategory = menuCategoryMap.getMenuCategory(); + Integer index = getInnerMenuCategoriesResponseIndex(innerMenuCategoriesResponses, menuCategory); + InnerMenuCategoriesResponse.InnerMenuResponse innerMenuResponse = InnerMenuCategoriesResponse.InnerMenuResponse.from( + menuCategoryMap); + if (index != null) { + innerMenuCategoriesResponses.get(index).menus.add(innerMenuResponse); + } else { + List menuResponses = new ArrayList<>(); + menuResponses.add(innerMenuResponse); + innerMenuCategoriesResponses.add(new InnerMenuCategoriesResponse( + menuCategory.getId(), + menuCategory.getName(), + menuResponses + )); + } + } + } + return new ShopMenuResponse( + menus.size(), + innerMenuCategoriesResponses, + lastUpdatedAt + ); + } + + private static Integer getInnerMenuCategoriesResponseIndex( + List innerMenuCategoriesResponses, + MenuCategory menuCategory + ) { + for (int i = 0; i < innerMenuCategoriesResponses.size(); ++i) { + if (innerMenuCategoriesResponses.get(i).id.equals(menuCategory.getId())) { + return i; + } + } + return null; + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerMenuCategoriesResponse( + @Schema(example = "1", description = "카테고리 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "중식", description = "카테고리 이름", requiredMode = REQUIRED) + String name, + + @Schema(description = "해당 상점의 모든 메뉴 리스트") + List menus + ) { + + public static InnerMenuCategoriesResponse from(MenuCategory menuCategory) { + return new InnerMenuCategoriesResponse( + menuCategory.getId(), + menuCategory.getName(), + menuCategory.getMenuCategoryMaps().stream().map(InnerMenuResponse::from).toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerMenuResponse( + @Schema(example = "1", description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "탕수육", description = "이름", requiredMode = NOT_REQUIRED) + String name, + + @Schema(example = "false", description = "숨김 여부", requiredMode = REQUIRED) + Boolean isHidden, + + @Schema(example = "false", description = "단일 메뉴 여부", requiredMode = REQUIRED) + Boolean isSingle, + + @Schema(example = "10000", description = "단일 메뉴일때(is_single이 true일때)의 가격 / 단일 메뉴가 아니라면 null", requiredMode = NOT_REQUIRED) + Integer singlePrice, + + @Schema(description = "옵션이 있는 메뉴일때(is_single이 false일때)의 옵션에 따른 가격 리스트 / 단일 메뉴 라면 null", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(example = "저희 식당의 대표 메뉴 탕수육입니다.", description = "설명", requiredMode = NOT_REQUIRED) + String description, + + @Schema(description = "이미지 URL리스트", example = """ + [ "https://static.koreatech.in/example.png", "https://static.koreatech.in/example2.png" ] + """, requiredMode = NOT_REQUIRED) + List imageUrls + ) { + + public static InnerMenuResponse from(MenuCategoryMap menuCategoryMap) { + Menu menu = menuCategoryMap.getMenu(); + boolean isSingle = !menu.hasMultipleOption(); + return new InnerMenuResponse( + menu.getId(), + menu.getName(), + menu.isHidden(), + isSingle, + isSingle ? menu.getMenuOptions().get(0).getPrice() : null, + isSingle ? null : menu.getMenuOptions().stream().map(InnerOptionPrice::from).toList(), + menu.getDescription(), + menu.getMenuImages().stream().map(MenuImage::getImageUrl).toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerOptionPrice( + @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) + String option, + + @Schema(example = "26000", description = "가격", requiredMode = REQUIRED) + Integer price + ) { + + public static InnerOptionPrice from(MenuOption menuOption) { + return new InnerOptionPrice( + menuOption.getOption(), + menuOption.getPrice() + ); + } + } + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ShopResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopResponse.java new file mode 100644 index 000000000..cc71b496e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopResponse.java @@ -0,0 +1,157 @@ +package in.koreatech.koin.domain.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ShopResponse( + @Schema(example = "충청남도 천안시 동남구 병천면", description = "주소") + String address, + + @Schema(example = "true", description = "배달 가능 여부") + Boolean delivery, + + @Schema(example = "1000", description = "배달비", requiredMode = REQUIRED) + Integer deliveryPrice, + + @Schema(example = "string", description = "설명") + String description, + + @Schema(example = "1", description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이미지 URL 리스트") + List imageUrls, + + @Schema(description = "상점에 있는 메뉴 카테고리 리스트") + List menuCategories, + + @Schema(example = "수신반점", description = "이름") + String name, + + @Schema(description = "요일별 휴무 여부 및 장사 시간") + List open, + + @Schema(example = "true", description = "계좌 이체 가능 여부", requiredMode = REQUIRED) + Boolean payBank, + + @Schema(example = "false", description = "카드 계산 가능 여부", requiredMode = REQUIRED) + Boolean payCard, + + @Schema(example = "041-000-0000", description = "전화번호", requiredMode = NOT_REQUIRED) + String phone, + + @Schema(description = "소속된 상점 카테고리 리스트") + List shopCategories, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(example = "2024-03-01", description = "업데이트 날짜", requiredMode = REQUIRED) + LocalDateTime updatedAt, + + @Schema(example = "true", description = "상점 이벤트 진행 여부", requiredMode = REQUIRED) + Boolean isEvent +) { + + public static ShopResponse from(Shop shop, Boolean isEvent) { + return new ShopResponse( + shop.getAddress(), + shop.isDelivery(), + shop.getDeliveryPrice(), + (shop.getDescription() == null || shop.getDescription().isBlank()) ? "-" : shop.getDescription(), + shop.getId(), + shop.getShopImages().stream() + .map(ShopImage::getImageUrl) + .toList(), + shop.getMenuCategories().stream().map(menuCategory -> + new InnerMenuCategory( + menuCategory.getId(), + menuCategory.getName() + ) + ).toList(), + shop.getName(), + shop.getShopOpens().stream().map(shopOpen -> + new InnerShopOpen( + shopOpen.getDayOfWeek(), + shopOpen.isClosed(), + shopOpen.getOpenTime(), + shopOpen.getCloseTime() + ) + ).toList(), + shop.isPayBank(), + shop.isPayCard(), + shop.getPhone(), + shop.getShopCategories().stream().map(shopCategoryMap -> { + ShopCategory shopCategory = shopCategoryMap.getShopCategory(); + return new InnerShopCategory( + shopCategory.getId(), + shopCategory.getName() + ); + }).toList(), + shop.getUpdatedAt(), + isEvent + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopOpen( + @Schema(example = "MONDAY", description = """ + 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] + """, requiredMode = REQUIRED) + String dayOfWeek, + + @Schema(example = "false", description = "휴무 여부", requiredMode = REQUIRED) + Boolean closed, + + @JsonFormat(pattern = "HH:mm") + @Schema(example = "02:00", description = "오픈 시간", requiredMode = NOT_REQUIRED) + LocalTime openTime, + + @JsonFormat(pattern = "HH:mm") + @Schema(example = "16:00", description = "마감 시간", requiredMode = NOT_REQUIRED) + LocalTime closeTime + ) { + + public static InnerShopOpen from(ShopOpen shopOpen) { + return new InnerShopOpen( + shopOpen.getDayOfWeek(), + shopOpen.isClosed(), + shopOpen.getOpenTime(), + shopOpen.getCloseTime() + ); + } + } + + private record InnerShopCategory( + @Schema(example = "1", description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "중국집", description = "이름", requiredMode = REQUIRED) + String name + ) { + + } + + private record InnerMenuCategory( + @Schema(example = "1", description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "대표 메뉴", description = "이름", requiredMode = REQUIRED) + String name + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ShopsResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopsResponse.java new file mode 100644 index 000000000..36ed39613 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopsResponse.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.dto.ShopResponse.InnerShopOpen; +import in.koreatech.koin.domain.shop.model.Shop; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ShopsResponse( + @Schema(example = "100", description = "상점 개수", requiredMode = REQUIRED) + Integer count, + + @Schema(description = "상점 정보") + List shops +) { + + public static ShopsResponse from(List shops) { + return new ShopsResponse(shops.size(), shops); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopResponse( + @Schema(example = "[1, 2, 3]", description = " 속해있는 상점 카테고리들의 고유 id 리스트", requiredMode = NOT_REQUIRED) + List categoryIds, + + @Schema(example = "true", description = "배달 가능 여부", requiredMode = REQUIRED) + boolean delivery, + + @Schema(example = "1", description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "수신반점", description = "이름", requiredMode = REQUIRED) + String name, + + @Schema(description = "요일별 휴무 여부 및 장사 시간") + List open, + + @Schema(example = "true", description = "계좌 이체 가능 여부", requiredMode = REQUIRED) + boolean payBank, + + @Schema(example = "true", description = "카드 계산 가능 여부", requiredMode = REQUIRED) + boolean payCard, + + @Schema(example = "041-000-0000", description = "전화번호", requiredMode = NOT_REQUIRED) + String phone, + + @Schema(example = "true", description = "삭제 여부", requiredMode = REQUIRED) + boolean isEvent, + + @Schema(example = "true", description = "운영중 여부", requiredMode = REQUIRED) + boolean isOpen + ) { + + public static InnerShopResponse from(Shop shop, boolean isEvent, boolean isOpen) { + return new InnerShopResponse( + shop.getShopCategories().stream().map(shopCategoryMap -> + shopCategoryMap.getShopCategory().getId() + ).toList(), + shop.isDelivery(), + shop.getId(), + shop.getName(), + shop.getShopOpens().stream().map(InnerShopOpen::from).toList(), + shop.isPayBank(), + shop.isPayCard(), + shop.getPhone(), + isEvent, + isOpen + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/exception/MenuCategoryNotFoundException.java b/src/main/java/in/koreatech/koin/domain/shop/exception/MenuCategoryNotFoundException.java new file mode 100644 index 000000000..13aab237f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/exception/MenuCategoryNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.shop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class MenuCategoryNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 메뉴카테고리입니다."; + + public MenuCategoryNotFoundException(String message) { + super(message); + } + + public MenuCategoryNotFoundException(String message, String detail) { + super(message, detail); + } + + public static MenuCategoryNotFoundException withDetail(String detail) { + return new MenuCategoryNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/exception/MenuNotFoundException.java b/src/main/java/in/koreatech/koin/domain/shop/exception/MenuNotFoundException.java new file mode 100644 index 000000000..2b7ba561d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/exception/MenuNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.shop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class MenuNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 메뉴입니다."; + + public MenuNotFoundException(String message) { + super(message); + } + + public MenuNotFoundException(String message, String detail) { + super(message, detail); + } + + public static MenuNotFoundException withDetail(String detail) { + return new MenuNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/exception/ShopCategoryNotFoundException.java b/src/main/java/in/koreatech/koin/domain/shop/exception/ShopCategoryNotFoundException.java new file mode 100644 index 000000000..a46e600f8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/exception/ShopCategoryNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.shop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class ShopCategoryNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 카테고리입니다."; + + public ShopCategoryNotFoundException(String message) { + super(message); + } + + public ShopCategoryNotFoundException(String message, String detail) { + super(message, detail); + } + + public static ShopCategoryNotFoundException withDetail(String detail) { + return new ShopCategoryNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/exception/ShopNotFoundException.java b/src/main/java/in/koreatech/koin/domain/shop/exception/ShopNotFoundException.java new file mode 100644 index 000000000..101c8576f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/exception/ShopNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.shop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class ShopNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 상점입니다."; + + public ShopNotFoundException(String message) { + super(message); + } + + public ShopNotFoundException(String message, String detail) { + super(message, detail); + } + + public static ShopNotFoundException withDetail(String detail) { + return new ShopNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/EventArticle.java b/src/main/java/in/koreatech/koin/domain/shop/model/EventArticle.java new file mode 100644 index 000000000..ba7de7968 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/EventArticle.java @@ -0,0 +1,161 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "event_articles") +@Where(clause = "is_deleted=0") +@SQLDelete(sql = "UPDATE event_articles SET is_deleted = true WHERE id = ?") +@NoArgsConstructor(access = PROTECTED) +public class EventArticle extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "shop_id") + private Shop shop; + + @Size(max = 255) + @NotNull + @Column(name = "title", nullable = false) + private String title; + + @OneToMany(mappedBy = "eventArticle", orphanRemoval = true, cascade = ALL) + private List thumbnailImages = new ArrayList<>(); + + /** + * 미사용 컬럼 + * TODO: 마이그레이션 종료 후 flyway로 제거 + */ + @Size(max = 50) + @NotNull + @Column(name = "event_title", nullable = false) + private String eventTitle = ""; + + @NotNull + @Column(name = "content", nullable = false) + private String content; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + /** + * 미사용 컬럼 + * TODO: 마이그레이션 종료 후 flyway로 제거 + */ + @Size(max = 50) + @NotNull + @Column(name = "nickname", nullable = false) + private String nickname = ""; + + /** + * 미사용 컬럼 + * TODO: 마이그레이션 종료 후 flyway로 제거 + */ + @Size(max = 255) + @Column(name = "thumbnail") + private String thumbnail; + + @NotNull + @Column(name = "hit", nullable = false) + private Integer hit; + + @Size(max = 45) + @NotNull + @Column(name = "ip", nullable = false) + private String ip; + + @NotNull + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @NotNull + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + /** + * 미사용 컬럼 + * TODO: 마이그레이션 종료 후 flyway로 제거 + */ + @NotNull + @Column(name = "comment_count", nullable = false) + private boolean commentCount = false; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Builder + private EventArticle( + Shop shop, + String title, + String content, + User user, + Integer hit, + String ip, + LocalDate startDate, + LocalDate endDate + ) { + this.shop = shop; + this.title = title; + this.content = content; + this.user = user; + this.hit = hit; + this.ip = ip; + this.startDate = startDate; + this.endDate = endDate; + } + + public void modifyArticle( + String title, + String content, + List thumbnailImages, + LocalDate startDate, + LocalDate endDate, + EntityManager entityManager + ) { + this.title = title; + this.content = content; + this.startDate = startDate; + this.endDate = endDate; + this.thumbnailImages.clear(); + entityManager.flush(); + for (String imageUrl : thumbnailImages) { + this.thumbnailImages.add(EventArticleImage.builder() + .eventArticle(this) + .thumbnailImage(imageUrl) + .build()); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/EventArticleImage.java b/src/main/java/in/koreatech/koin/domain/shop/model/EventArticleImage.java new file mode 100644 index 000000000..52880cc66 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/EventArticleImage.java @@ -0,0 +1,48 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "event_article_thumbnail_images") +@NoArgsConstructor(access = PROTECTED) +public class EventArticleImage extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", nullable = false) + private EventArticle eventArticle; + + @Size(max = 255) + @NotNull + @Column(name = "thumbnail_image") + private String thumbnailImage; + + @Builder + private EventArticleImage( + EventArticle eventArticle, + String thumbnailImage + ) { + this.eventArticle = eventArticle; + this.thumbnailImage = thumbnailImage; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java b/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java new file mode 100644 index 000000000..481990e50 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java @@ -0,0 +1,142 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.util.ArrayList; +import java.util.List; + +import in.koreatech.koin.domain.shop.dto.ModifyMenuRequest; +import in.koreatech.koin.domain.shop.dto.ModifyMenuRequest.InnerOptionPrice; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "shop_menus") +@NoArgsConstructor(access = PROTECTED) +public class Menu extends BaseEntity { + + private static final int SINGLE_OPTION_COUNT = 1; + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @NotNull + @Column(name = "shop_id", nullable = false) + private Integer shopId; + + @Size(max = 255) + @NotNull + @Column(name = "name", nullable = false) + private String name; + + @Size(max = 255) + @Column(name = "description") + private String description; + + @NotNull + @Column(name = "is_hidden", nullable = false) + private boolean isHidden = false; + + @OneToMany(mappedBy = "menu", orphanRemoval = true, cascade = ALL) + private List menuCategoryMaps = new ArrayList<>(); + + @OneToMany(mappedBy = "menu", orphanRemoval = true, cascade = ALL) + private List menuOptions = new ArrayList<>(); + + @OneToMany(mappedBy = "menu", orphanRemoval = true, cascade = ALL) + private List menuImages = new ArrayList<>(); + + @Builder + private Menu( + Integer shopId, + String name, + String description + ) { + this.shopId = shopId; + this.name = name; + this.description = description; + } + + public boolean hasMultipleOption() { + return menuOptions.size() > SINGLE_OPTION_COUNT; + } + + @Override + public String toString() { + return "Menu{" + + "id=" + id + + ", shopId=" + shopId + + ", name='" + name + '\'' + + '}'; + } + + public void modifyMenu( + String name, + String description + ) { + this.name = name; + this.description = description; + } + + public void modifyMenuImages(List imageUrls, EntityManager entityManager) { + this.menuImages.clear(); + entityManager.flush(); + for (String imageUrl : imageUrls) { + MenuImage newMenuImage = MenuImage.builder() + .imageUrl(imageUrl) + .menu(this) + .build(); + this.menuImages.add(newMenuImage); + } + } + + public void modifyMenuCategories(List menuCategories, EntityManager entityManager) { + this.menuCategoryMaps.clear(); + entityManager.flush(); + for (MenuCategory menuCategory : menuCategories) { + MenuCategoryMap menuCategoryMap = MenuCategoryMap.builder() + .menu(this) + .menuCategory(menuCategory) + .build(); + this.menuCategoryMaps.add(menuCategoryMap); + } + } + + public void modifyMenuSingleOptions(ModifyMenuRequest modifyMenuRequest, EntityManager entityManager) { + this.menuOptions.clear(); + entityManager.flush(); + MenuOption menuOption = MenuOption.builder() + .price(modifyMenuRequest.singlePrice()) + .menu(this) + .build(); + this.menuOptions.add(menuOption); + } + + public void modifyMenuMultipleOptions(List innerOptionPrice, EntityManager entityManager) { + this.menuOptions.clear(); + entityManager.flush(); + for (var option : innerOptionPrice) { + MenuOption menuOption = MenuOption.builder() + .option(option.option()) + .price(option.price()) + .menu(this) + .build(); + this.menuOptions.add(menuOption); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/MenuCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/MenuCategory.java new file mode 100644 index 000000000..e2e5a4960 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/MenuCategory.java @@ -0,0 +1,58 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.util.ArrayList; +import java.util.List; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "shop_menu_categories") +@NoArgsConstructor(access = PROTECTED) +public final class MenuCategory extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_id", nullable = false) + private Shop shop; + + @Size(max = 255) + @NotNull + @Column(name = "name", nullable = false, unique = true) + private String name; + + @OneToMany(mappedBy = "menuCategory", orphanRemoval = true, cascade = ALL) + private List menuCategoryMaps = new ArrayList<>(); + + @Builder + private MenuCategory(Shop shop, String name) { + this.shop = shop; + this.name = name; + } + + public void modifyName(String name) { + this.name = name; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/MenuCategoryMap.java b/src/main/java/in/koreatech/koin/domain/shop/model/MenuCategoryMap.java new file mode 100644 index 000000000..16b46ae8d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/MenuCategoryMap.java @@ -0,0 +1,81 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "shop_menu_category_map", uniqueConstraints = { + @UniqueConstraint( + name = "SHOP_MENU_ID_AND_SHOP_MENU_CATEGORY_ID", + columnNames = {"shop_menu_id", "shop_menu_category_id"} + )} +) +@NoArgsConstructor(access = PROTECTED) +public class MenuCategoryMap { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_menu_id") + private Menu menu; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_menu_category_id") + private MenuCategory menuCategory; + + @Builder + private MenuCategoryMap(Menu menu, MenuCategory menuCategory) { + this.menu = menu; + this.menuCategory = menuCategory; + } + + public static MenuCategoryMap create() { + return new MenuCategoryMap(); + } + + public void map(Menu menu, MenuCategory menuCategory) { + setMenu(menu); + setMenuCategory(menuCategory); + } + + private void setMenu(Menu menu) { + if (menu.equals(this.menu)) { + return; + } + + if (this.menu != null) { + this.menu.getMenuCategoryMaps().remove(this); + } + + this.menu = menu; + menu.getMenuCategoryMaps().add(this); + } + + private void setMenuCategory(MenuCategory menuCategory) { + if (menuCategory.equals(this.menuCategory)) { + return; + } + + if (this.menuCategory != null) { + this.menuCategory.getMenuCategoryMaps().remove(this); + } + + this.menuCategory = menuCategory; + menuCategory.getMenuCategoryMaps().add(this); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/MenuImage.java b/src/main/java/in/koreatech/koin/domain/shop/model/MenuImage.java new file mode 100644 index 000000000..e595f521b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/MenuImage.java @@ -0,0 +1,62 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "shop_menu_images", uniqueConstraints = { + @UniqueConstraint( + name = "SHOP_MENU_ID_AND_IMAGE_URL", + columnNames = {"shop_menu_id", "image_url"} + )} +) +@NoArgsConstructor(access = PROTECTED) +public class MenuImage { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "shop_menu_id") + private Menu menu; + + @Size(max = 255) + @NotNull + @Column(name = "image_url", nullable = false) + private String imageUrl; + + @Builder + private MenuImage(Menu menu, String imageUrl) { + this.menu = menu; + this.imageUrl = imageUrl; + } + + public void setMenu(Menu menu) { + if (menu.equals(this.menu)) { + return; + } + + if (this.menu != null) { + this.menu.getMenuImages().remove(this); + } + this.menu = menu; + this.menu.getMenuImages().add(this); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/MenuOption.java b/src/main/java/in/koreatech/koin/domain/shop/model/MenuOption.java new file mode 100644 index 000000000..ed1ba4436 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/MenuOption.java @@ -0,0 +1,69 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "shop_menu_details", uniqueConstraints = { + @UniqueConstraint( + name = "SHOP_MENU_ID_AND_OPTION_AND_PRICE", + columnNames = {"shop_menu_id", "option", "price"} + )} +) +@NoArgsConstructor(access = PROTECTED) +public class MenuOption extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_menu_id") + private Menu menu; + + @Size(max = 255) + @Column(name = "`option`") + private String option; + + @NotNull + @Column(name = "price", nullable = false) + @PositiveOrZero + private Integer price; + + @Builder + private MenuOption(String option, Integer price, Menu menu) { + this.option = option; + this.price = price; + this.menu = menu; + } + + public void setMenu(Menu menu) { + if (menu.equals(this.menu)) { + return; + } + + if (this.menu != null) { + this.menu.getMenuOptions().remove(this); + } + this.menu = menu; + this.menu.getMenuOptions().add(this); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java new file mode 100644 index 000000000..ae69983c8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java @@ -0,0 +1,247 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.CascadeType.MERGE; +import static jakarta.persistence.CascadeType.PERSIST; +import static jakarta.persistence.CascadeType.REFRESH; +import static jakarta.persistence.CascadeType.REMOVE; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.shop.dto.ModifyShopRequest.InnerShopOpen; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shops") +@Where(clause = "is_deleted=0") +public class Shop extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", referencedColumnName = "user_id") + private Owner owner; + + @Size(max = 50) + @NotNull + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Size(max = 50) + @NotNull + @Column(name = "internal_name", nullable = false, length = 50) + private String internalName; + + @Size(max = 3) + @Column(name = "chosung", length = 3) + private String chosung; + + @Size(max = 50) + @Column(name = "phone", length = 50) + private String phone; + + @Column(name = "address") + private String address; + + @Column(name = "description") + private String description; + + @NotNull + @Column(name = "delivery", nullable = false) + private boolean delivery = false; + + @NotNull + @Column(name = "delivery_price", nullable = false) + @PositiveOrZero + private Integer deliveryPrice; + + @NotNull + @Column(name = "pay_card", nullable = false) + private boolean payCard = false; + + @NotNull + @Column(name = "pay_bank", nullable = false) + private boolean payBank = false; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @NotNull + @Column(name = "is_event", nullable = false) + private boolean isEvent = false; + + @Column(name = "remarks") + private String remarks; + + @NotNull + @Column(name = "hit", nullable = false) + private Integer hit; + + @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) + private List shopCategories = new ArrayList<>(); + + @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) + private List shopOpens = new ArrayList<>(); + + @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) + private List shopImages = new ArrayList<>(); + + @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) + private List menuCategories = new ArrayList<>(); + + @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) + private List eventArticles = new ArrayList<>(); + + @Builder + private Shop( + Owner owner, + String name, + String internalName, + String chosung, + String phone, + String address, + String description, + boolean delivery, + Integer deliveryPrice, + boolean payCard, + boolean payBank, + boolean isDeleted, + boolean isEvent, + String remarks, + Integer hit + ) { + this.owner = owner; + this.name = name; + this.internalName = internalName; + this.chosung = chosung; + this.phone = phone; + this.address = address; + this.description = description; + this.delivery = delivery; + this.deliveryPrice = deliveryPrice; + this.payCard = payCard; + this.payBank = payBank; + this.isDeleted = isDeleted; + this.isEvent = isEvent; + this.remarks = remarks; + this.hit = hit; + } + + public void modifyShop( + String name, + String phone, + String address, + String description, + boolean delivery, + Integer deliveryPrice, + Boolean payCard, + boolean payBank + ) { + this.address = address; + this.delivery = delivery; + this.deliveryPrice = deliveryPrice; + this.description = description; + this.name = name; + this.payBank = payBank; + this.payCard = payCard; + this.phone = phone; + } + + public void modifyShopImages(List imageUrls, EntityManager entityManager) { + this.shopImages.clear(); + entityManager.flush(); + for (String imageUrl : imageUrls) { + ShopImage shopImage = ShopImage.builder().shop(this).imageUrl(imageUrl).build(); + this.shopImages.add(shopImage); + } + } + + public void modifyShopOpens(List innerShopOpens, EntityManager entityManager) { + this.shopOpens.clear(); + entityManager.flush(); + for (var open : innerShopOpens) { + ShopOpen shopOpen = open.toEntity(this); + this.shopOpens.add(shopOpen); + } + } + + public void modifyShopCategories(List shopCategories, EntityManager entityManager) { + this.shopCategories.clear(); + entityManager.flush(); + for (ShopCategory shopCategory : shopCategories) { + ShopCategoryMap shopCategoryMap = ShopCategoryMap.builder().shop(this).shopCategory(shopCategory).build(); + this.shopCategories.add(shopCategoryMap); + } + } + + public boolean isOpen(LocalDateTime now) { + String currentDayOfWeek = now.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.US).toUpperCase(); + String previousDayOfWeek = now.minusDays(1) + .getDayOfWeek() + .getDisplayName(TextStyle.FULL, Locale.US) + .toUpperCase(); + LocalTime currentTime = now.toLocalTime(); + for (ShopOpen shopOpen : this.shopOpens) { + if (shopOpen.isClosed()) { + continue; + } + if (shopOpen.getDayOfWeek().equals(currentDayOfWeek) && (isShopOpenToday(shopOpen, currentTime))) { + return true; + } + if (shopOpen.getDayOfWeek().equals(previousDayOfWeek) && (isShopOpenAtNightShift(shopOpen, currentTime))) { + return true; + } + } + return false; + } + + private boolean isShopOpenToday(ShopOpen shopOpen, LocalTime currentTime) { + long currTime = currentTime.toNanoOfDay(); + long openTime = shopOpen.getOpenTime().toNanoOfDay(); + long closeTime = shopOpen.getCloseTime().toNanoOfDay(); + if (closeTime == 0 && openTime == 0) { + return true; + } + if (closeTime < openTime) { + closeTime += (LocalTime.of(12, 0).toNanoOfDay() * 2); + } + return (closeTime == 0 && openTime <= currTime) || (openTime <= currTime && currTime <= closeTime); + } + + private boolean isShopOpenAtNightShift(ShopOpen shopOpen, LocalTime currentTime) { + long currTime = currentTime.toNanoOfDay(); + long openTime = shopOpen.getOpenTime().toNanoOfDay(); + long closeTime = shopOpen.getCloseTime().toNanoOfDay(); + return 0 < closeTime && currTime <= closeTime && closeTime <= openTime; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java new file mode 100644 index 000000000..a290eed14 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java @@ -0,0 +1,58 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.CascadeType.PERSIST; +import static jakarta.persistence.CascadeType.REMOVE; +import static jakarta.persistence.GenerationType.IDENTITY; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "shop_categories") +@Where(clause = "is_deleted=0") +public class ShopCategory extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Size(max = 255) + @Column(name = "name", nullable = false) + private String name; + + @Size(max = 255) + @Column(name = "image_url") + private String imageUrl; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @OneToMany(mappedBy = "shopCategory", orphanRemoval = true, cascade = {PERSIST, REMOVE}) + private List shopCategoryMaps = new ArrayList<>(); + + @Builder + private ShopCategory(String name, String imageUrl, Boolean isDeleted) { + this.name = name; + this.imageUrl = imageUrl; + this.isDeleted = isDeleted; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategoryMap.java b/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategoryMap.java new file mode 100644 index 000000000..77ae334d2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategoryMap.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shop_category_map", + uniqueConstraints = { + @UniqueConstraint( + name = "SHOP_ID_AND_SHOP_CATEGORY_ID", + columnNames = {"shop_id", "shop_category_id"} + ) + } +) +public class ShopCategoryMap extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_id", referencedColumnName = "id", nullable = false) + private Shop shop; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_category_id", referencedColumnName = "id", nullable = false) + private ShopCategory shopCategory; + + @Builder + private ShopCategoryMap(Shop shop, ShopCategory shopCategory) { + this.shop = shop; + this.shopCategory = shopCategory; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/ShopEventListener.java b/src/main/java/in/koreatech/koin/domain/shop/model/ShopEventListener.java new file mode 100644 index 000000000..107a9ca26 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/ShopEventListener.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.domain.shop.model; + +import static in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType.SHOP_EVENT; +import static in.koreatech.koin.global.fcm.MobileAppPath.SHOP; +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import in.koreatech.koin.domain.ownershop.EventArticleCreateShopEvent; +import in.koreatech.koin.global.domain.notification.model.NotificationFactory; +import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.global.domain.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional(propagation = Propagation.REQUIRES_NEW) +public class ShopEventListener { + + private final NotificationService notificationService; + private final NotificationFactory notificationFactory; + private final NotificationSubscribeRepository notificationSubscribeRepository; + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onShopEventCreate(EventArticleCreateShopEvent event) { + var notifications = notificationSubscribeRepository.findAllBySubscribeType(SHOP_EVENT) + .stream() + .filter(subscribe -> subscribe.getUser().getDeviceToken() != null) + .map(subscribe -> notificationFactory.generateShopEventCreateNotification( + SHOP, + event.shopName(), + event.title(), + subscribe.getUser() + )).toList(); + notificationService.push(notifications); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/ShopImage.java b/src/main/java/in/koreatech/koin/domain/shop/model/ShopImage.java new file mode 100644 index 000000000..70d5e068b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/ShopImage.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shop_images", + uniqueConstraints = { + @UniqueConstraint( + name = "SHOP_ID_AND_IMAGE_URL", + columnNames = {"shop_id", "image_url"} + ) + } +) +public class ShopImage extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_id", referencedColumnName = "id", nullable = false) + private Shop shop; + + @Size(max = 255) + @Column(name = "image_url") + private String imageUrl; + + @Builder + private ShopImage(Shop shop, String imageUrl) { + this.shop = shop; + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/ShopOpen.java b/src/main/java/in/koreatech/koin/domain/shop/model/ShopOpen.java new file mode 100644 index 000000000..9a7eeeb61 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/ShopOpen.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.shop.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalTime; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.global.config.LocalTimeAttributeConverter; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shop_opens") +@Where(clause = "is_deleted=0") +public class ShopOpen extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_id", referencedColumnName = "id", nullable = false) + private Shop shop; + + @Size(max = 10) + @Column(name = "day_of_week", nullable = false) + private String dayOfWeek; + + @NotNull + @Column(name = "closed", nullable = false) + private boolean closed; + + @NotNull + @Column(name = "open_time") + @Convert(converter = LocalTimeAttributeConverter.class) + private LocalTime openTime; + + @NotNull + @Column(name = "close_time") + @Convert(converter = LocalTimeAttributeConverter.class) + private LocalTime closeTime; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Builder + private ShopOpen( + Shop shop, + String dayOfWeek, + boolean closed, + LocalTime openTime, + LocalTime closeTime + ) { + this.shop = shop; + this.dayOfWeek = dayOfWeek; + this.closed = closed; + this.openTime = openTime; + this.closeTime = closeTime; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/EventArticleImageRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/EventArticleImageRepository.java new file mode 100644 index 000000000..9b98993c6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/EventArticleImageRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.domain.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.EventArticleImage; + +public interface EventArticleImageRepository extends Repository { + + void save(EventArticleImage eventArticleImage); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/EventArticleRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/EventArticleRepository.java new file mode 100644 index 000000000..8cd3837c0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/EventArticleRepository.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.domain.shop.repository; + +import java.time.LocalDate; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.ownershop.exception.EventArticleNotFoundException; +import in.koreatech.koin.domain.shop.model.EventArticle; + +public interface EventArticleRepository extends Repository { + + EventArticle save(EventArticle eventArticle); + + @Query(""" + SELECT COUNT(e) > 0 FROM EventArticle e + WHERE :now BETWEEN e.startDate AND e.endDate + AND e.shop.id = :shopId + """) + boolean isDurationEvent(@Param("shopId") Integer shopId, @Param("now") LocalDate now); + + Optional findById(Integer id); + + default EventArticle getById(Integer eventId) { + return findById(eventId).orElseThrow(() -> EventArticleNotFoundException.withDetail("eventId: " + eventId)); + } + + void deleteById(Integer eventId); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/MenuCategoryMapRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuCategoryMapRepository.java new file mode 100644 index 000000000..dac84b2d7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuCategoryMapRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.domain.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; + +public interface MenuCategoryMapRepository extends Repository { + + MenuCategoryMap save(MenuCategoryMap menuCategoryMap); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/MenuCategoryRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuCategoryRepository.java new file mode 100644 index 000000000..d88cfcc4f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuCategoryRepository.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.exception.MenuCategoryNotFoundException; +import in.koreatech.koin.domain.shop.model.MenuCategory; + +public interface MenuCategoryRepository extends Repository { + + List findAllByShopId(Integer shopId); + + MenuCategory save(MenuCategory menuCategory); + + Optional findById(Integer id); + + List findAllByIdIn(List ids); + + default MenuCategory getById(Integer id) { + return findById(id).orElseThrow(() -> MenuCategoryNotFoundException.withDetail("categoryId: " + id)); + } + + Void deleteById(Integer id); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/MenuDetailRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuDetailRepository.java new file mode 100644 index 000000000..1ebc0232e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuDetailRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.domain.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.MenuOption; + +public interface MenuDetailRepository extends Repository { + + MenuOption save(MenuOption menuOption); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/MenuImageRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuImageRepository.java new file mode 100644 index 000000000..0cd681bcc --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuImageRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.domain.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.MenuImage; + +public interface MenuImageRepository extends Repository { + + MenuImage save(MenuImage menuImage); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/MenuRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuRepository.java new file mode 100644 index 000000000..ad68626fe --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/MenuRepository.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.exception.MenuNotFoundException; +import in.koreatech.koin.domain.shop.model.Menu; + +public interface MenuRepository extends Repository { + + Optional findById(Integer menuId); + + Menu save(Menu menu); + + void deleteById(Integer id); + + default Menu getById(Integer menuId) { + return findById(menuId).orElseThrow(() -> MenuNotFoundException.withDetail("menuId: " + menuId)); + } + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/ShopCategoryMapRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopCategoryMapRepository.java new file mode 100644 index 000000000..3184c9063 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopCategoryMapRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.shop.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; + +public interface ShopCategoryMapRepository extends Repository { + + ShopCategoryMap save(ShopCategoryMap shopCategoryMap); + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/ShopCategoryRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopCategoryRepository.java new file mode 100644 index 000000000..bf715830f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopCategoryRepository.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.exception.ShopCategoryNotFoundException; +import in.koreatech.koin.domain.shop.model.ShopCategory; + +public interface ShopCategoryRepository extends Repository { + + Optional findById(Integer shopCategoryId); + + ShopCategory save(ShopCategory shopCategory); + + List findAllByIdIn(List ids); + + default ShopCategory getById(Integer shopCategoryId) { + return findById(shopCategoryId) + .orElseThrow(() -> ShopCategoryNotFoundException.withDetail("shopCategoryId: " + shopCategoryId)); + } + + List findAll(); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/ShopImageRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopImageRepository.java new file mode 100644 index 000000000..46a24c88f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopImageRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.shop.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.ShopImage; + +public interface ShopImageRepository extends Repository { + + ShopImage save(ShopImage shopImage); + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/ShopOpenRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopOpenRepository.java new file mode 100644 index 000000000..f47ac5a47 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopOpenRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.shop.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.ShopOpen; + +public interface ShopOpenRepository extends Repository { + + ShopOpen save(ShopOpen shopOpen); + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/ShopRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopRepository.java new file mode 100644 index 000000000..feb2e4ced --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/ShopRepository.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.domain.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.exception.ShopNotFoundException; +import in.koreatech.koin.domain.shop.model.Shop; + +public interface ShopRepository extends Repository { + + Shop save(Shop shop); + + List findAllByOwnerId(Integer ownerId); + + Optional findById(Integer shopId); + + Optional findByOwnerId(Integer ownerId); + + default Shop getById(Integer shopId) { + return findById(shopId) + .orElseThrow(() -> ShopNotFoundException.withDetail("shopId: " + shopId)); + } + + default Shop getByOwnerId(Integer ownerId) { + return findByOwnerId(ownerId) + .orElseThrow(() -> ShopNotFoundException.withDetail("ownerId: " + ownerId)); + } + + List findAll(); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java new file mode 100644 index 000000000..a5c0e5c6d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java @@ -0,0 +1,100 @@ +package in.koreatech.koin.domain.shop.service; + +import static in.koreatech.koin.domain.shop.dto.ShopsResponse.InnerShopResponse; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.shop.dto.MenuCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.MenuDetailResponse; +import in.koreatech.koin.domain.shop.dto.ShopCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.ShopEventsResponse; +import in.koreatech.koin.domain.shop.dto.ShopMenuResponse; +import in.koreatech.koin.domain.shop.dto.ShopResponse; +import in.koreatech.koin.domain.shop.dto.ShopsResponse; +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.repository.EventArticleRepository; +import in.koreatech.koin.domain.shop.repository.MenuCategoryRepository; +import in.koreatech.koin.domain.shop.repository.MenuRepository; +import in.koreatech.koin.domain.shop.repository.ShopCategoryRepository; +import in.koreatech.koin.domain.shop.repository.ShopRepository; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ShopService { + + private final Clock clock; + private final MenuRepository menuRepository; + private final MenuCategoryRepository menuCategoryRepository; + private final ShopRepository shopRepository; + private final ShopCategoryRepository shopCategoryRepository; + private final EventArticleRepository eventArticleRepository; + + public MenuDetailResponse findMenu(Integer menuId) { + Menu menu = menuRepository.getById(menuId); + + List menuCategories = menu.getMenuCategoryMaps() + .stream() + .map(MenuCategoryMap::getMenuCategory) + .toList(); + + return MenuDetailResponse.createMenuDetailResponse(menu, menuCategories); + } + + public MenuCategoriesResponse getMenuCategories(Integer shopId) { + Shop shop = shopRepository.getById(shopId); + List menuCategories = menuCategoryRepository.findAllByShopId(shop.getId()); + return MenuCategoriesResponse.from(menuCategories); + } + + public ShopResponse getShop(Integer shopId) { + Shop shop = shopRepository.getById(shopId); + boolean eventDuration = eventArticleRepository.isDurationEvent(shopId, LocalDate.now(clock)); + return ShopResponse.from(shop, eventDuration); + } + + public ShopMenuResponse getShopMenus(Integer shopId) { + shopRepository.getById(shopId); + List menus = menuRepository.findAllByShopId(shopId); + return ShopMenuResponse.from(menus); + } + + public ShopsResponse getShops() { + List shops = shopRepository.findAll(); + LocalDateTime now = LocalDateTime.now(clock); + List innerShopResponses = shops.stream().map(shop -> { + boolean isDurationEvent = eventArticleRepository.isDurationEvent(shop.getId(), now.toLocalDate()); + return InnerShopResponse.from(shop, isDurationEvent, shop.isOpen(now)); + }) + .sorted(Comparator.comparing(InnerShopResponse::isOpen, Collections.reverseOrder())).toList(); + return ShopsResponse.from(innerShopResponses); + } + + public ShopCategoriesResponse getShopsCategories() { + List shopCategories = shopCategoryRepository.findAll(); + return ShopCategoriesResponse.from(shopCategories); + } + + public ShopEventsResponse getShopEvents(Integer shopId) { + Shop shop = shopRepository.getById(shopId); + return ShopEventsResponse.of(shop, clock); + } + + public ShopEventsResponse getAllEvents() { + List shops = shopRepository.findAll(); + return ShopEventsResponse.of(shops, clock); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java new file mode 100644 index 000000000..f169d8e51 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java @@ -0,0 +1,120 @@ +package in.koreatech.koin.domain.timetable.controller; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.timetable.dto.LectureResponse; +import in.koreatech.koin.domain.timetable.dto.SemesterResponse; +import in.koreatech.koin.domain.timetable.dto.TimeTableCreateRequest; +import in.koreatech.koin.domain.timetable.dto.TimeTableResponse; +import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Lecture: 시간표", description = "시간표 정보를 관리한다") +public interface TimetableApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "강의 목록 조회") + @GetMapping("/lectures") + ResponseEntity> getLecture( + String semesterDate + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "학기 정보 조회") + @GetMapping("/semesters") + ResponseEntity> getSemesters(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 정보 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/timetables") + ResponseEntity getTimeTables( + @RequestParam(value = "semester") String semester, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 정보 생성") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/timetables") + ResponseEntity createTimeTables( + @RequestBody TimeTableCreateRequest timeTableCreateRequest, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 정보 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/timetables") + ResponseEntity updateTimeTable( + @RequestBody TimeTableUpdateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "204", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/timetables") + ResponseEntity deleteTimeTableById( + @RequestParam(value = "id") Integer id, + @Auth(permit = {STUDENT}) Integer userId + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java new file mode 100644 index 000000000..fbe6b65e3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java @@ -0,0 +1,83 @@ +package in.koreatech.koin.domain.timetable.controller; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +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.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.timetable.dto.LectureResponse; +import in.koreatech.koin.domain.timetable.dto.SemesterResponse; +import in.koreatech.koin.domain.timetable.dto.TimeTableCreateRequest; +import in.koreatech.koin.domain.timetable.dto.TimeTableResponse; +import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.service.SemesterService; +import in.koreatech.koin.domain.timetable.service.TimetableService; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class TimetableController implements TimetableApi { + + private final TimetableService timetableService; + private final SemesterService semesterService; + + @GetMapping("/lectures") + public ResponseEntity> getLecture( + @RequestParam(name = "semester_date") String semester + ) { + List lectures = timetableService.getLecturesBySemester(semester); + return ResponseEntity.ok(lectures); + } + + @GetMapping("/semesters") + public ResponseEntity> getSemesters() { + List semesterResponse = semesterService.getSemesters(); + return ResponseEntity.ok(semesterResponse); + } + + @GetMapping("/timetables") + public ResponseEntity getTimeTables( + @RequestParam(name = "semester") String semester, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimeTableResponse timeTableResponse = timetableService.getTimeTables(userId, semester); + return ResponseEntity.ok(timeTableResponse); + } + + @PostMapping("/timetables") + public ResponseEntity createTimeTables( + @Valid @RequestBody TimeTableCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimeTableResponse timeTableResponse = timetableService.createTimeTables(userId, request); + return ResponseEntity.ok(timeTableResponse); + } + + @PutMapping("/timetables") + public ResponseEntity updateTimeTable( + @Valid @RequestBody TimeTableUpdateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimeTableResponse timeTableResponse = timetableService.updateTimeTables(userId, request); + return ResponseEntity.ok(timeTableResponse); + } + + @DeleteMapping("/timetable") + public ResponseEntity deleteTimeTableById( + @RequestParam(name = "id") Integer id, + @Auth(permit = {STUDENT}) Integer userId + ) { + timetableService.deleteTimeTable(id); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java new file mode 100644 index 000000000..3d2136a18 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java @@ -0,0 +1,85 @@ +package in.koreatech.koin.domain.timetable.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record LectureResponse( + + @Schema(name = "과목 코드", example = "ARB244", requiredMode = REQUIRED) + String code, + + @Schema(name = "과목 이름", example = "건축구조의 이해 및 실습", requiredMode = REQUIRED) + String name, + + @Schema(name = "대상 학년", example = "3", requiredMode = REQUIRED) + String grades, + + @Schema(name = "분반", example = "01", requiredMode = REQUIRED) + String lectureClass, + + @Schema(name = "수강 인원", example = "25", requiredMode = NOT_REQUIRED) + String regularNumber, + + @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = REQUIRED) + String department, + + @Schema(name = "대상", example = "디자 1 건축", requiredMode = REQUIRED) + String target, + + @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(name = "영어 수업인지", example = "N", requiredMode = REQUIRED) + String isEnglish, + + @Schema(name = "설계 학점", example = "0", requiredMode = REQUIRED) + String designScore, + + @Schema(name = "이러닝인지", example = "Y", requiredMode = REQUIRED) + String isElearning, + + @Schema(name = "강의 시간", example = "[200,201,202,203,204,205,206,207]", requiredMode = REQUIRED) + List classTime +) { + + public static LectureResponse from(Lecture lecture) { + return new LectureResponse( + lecture.getCode(), + lecture.getName(), + lecture.getGrades(), + lecture.getLectureClass(), + lecture.getRegularNumber(), + lecture.getDepartment(), + lecture.getTarget(), + lecture.getProfessor(), + lecture.getIsEnglish(), + lecture.getDesignScore(), + lecture.getIsElearning(), + toListClassTime(lecture.getClassTime()) + ); + } + + public static List toListClassTime(String classTime) { + if ("[]".equals(classTime)) { + return Collections.emptyList(); + } + + classTime = classTime.substring(1, classTime.length() - 1); + List numbers = List.of(classTime.split(",")); + + return numbers.stream() + .map(String::strip) + .map(Long::parseLong) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/SemesterResponse.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/SemesterResponse.java new file mode 100644 index 000000000..82096c37c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/SemesterResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.timetable.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.Semester; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record SemesterResponse( + @Schema(description = "id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "학기", example = "20241", requiredMode = REQUIRED) + String semester +) { + + public static SemesterResponse from(Semester semester) { + return new SemesterResponse( + semester.getId(), + semester.getSemester() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableCreateRequest.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableCreateRequest.java new file mode 100644 index 000000000..4310e88f6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableCreateRequest.java @@ -0,0 +1,101 @@ +package in.koreatech.koin.domain.timetable.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetable.model.TimeTable; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimeTableCreateRequest( + @Valid + @Schema(description = "시간표 정보", requiredMode = REQUIRED) + @NotNull(message = "시간표 정보를 입력해주세요.") + List timetable, + + @Schema(description = "학기 정보", example = "20192", requiredMode = REQUIRED) + @NotBlank(message = "학기 정보를 입력해주세요.") + String semester +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerTimeTableRequest( + @Schema(description = "과목 코드", example = "CPC490", requiredMode = NOT_REQUIRED) + String code, + + @Schema(description = "강의 이름", example = "운영체제", requiredMode = REQUIRED) + @NotBlank(message = "강의 이름을 입력해주세요.") + String classTitle, + + @Schema(description = "강의 시간", example = "[210, 211]", requiredMode = REQUIRED) + @NotNull(message = "강의 시간을 입력해주세요.") + List classTime, + + @Schema(description = "강의 장소", example = "2공학관", requiredMode = NOT_REQUIRED) + String classPlace, + + @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(description = "대상 학년", example = "3", requiredMode = REQUIRED) + @NotBlank(message = "대상 학년을 입력해주세요.") + String grades, + + @Schema(name = "분반", example = "01", requiredMode = REQUIRED) + @Size(max = 3, message = "분반은 3자 이하로 입력해주세요.") + String lectureClass, + + @Schema(name = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) + @Size(max = 200, message = "대상은 200자 이하로 입력해주세요.") + String target, + + @Schema(name = "수강 인원", example = "25", requiredMode = NOT_REQUIRED) + @Size(max = 4, message = "수강 인원은 4자 이하로 입력해주세요.") + String regularNumber, + + @Schema(name = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) + @Size(max = 4, message = "설계 학점은 4자 이하로 입력해주세요.") + String designScore, + + @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) + @Size(max = 30, message = "학부는 30자 이하로 입력해주세요.") + String department, + + @Schema(name = "memo", example = "메모메모", requiredMode = NOT_REQUIRED) + @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") + String memo + ) { + + public TimeTable toTimeTable(User user, Semester semester) { + return TimeTable.builder() + .user(user) + .semester(semester) + .code(this.code) + .classTitle(this.classTitle()) + .classTime(Arrays.toString(this.classTime().stream().toArray())) + .classPlace(this.classPlace()) + .professor(this.professor()) + .grades(this.grades()) + .lectureClass(this.lectureClass()) + .target(this.target()) + .regularNumber(this.regularNumber()) + .designScore(this.designScore()) + .department(this.department()) + .memo(this.memo()) + .isDeleted(false) + .build(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableResponse.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableResponse.java new file mode 100644 index 000000000..80ab89812 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableResponse.java @@ -0,0 +1,120 @@ +package in.koreatech.koin.domain.timetable.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.TimeTable; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimeTableResponse( + @Schema(name = "학기", example = "20241", requiredMode = REQUIRED) + String semester, + + @Schema(name = "시간표 상세정보") + List timetable, + + @Schema(name = "해당 학기 학점", example = "21") + Integer grades, + + @Schema(name = "전체 학기 학점", example = "121") + Integer totalGrades +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerTimeTableResponse( + @Schema(name = "시간표 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(name = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) + String regularNumber, + + @Schema(name = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) + String code, + + @Schema(description = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) + String designScore, + + @Schema(description = "강의 시간", example = "[204, 205, 206, 207, 302, 303]", requiredMode = REQUIRED) + List classTime, + + @Schema(description = "강의 장소", example = "2 공학관", requiredMode = REQUIRED) + String classPlace, + + @Schema(description = "메모", example = "null", requiredMode = NOT_REQUIRED) + String memo, + + @Schema(name = "대상 학년", example = "3", requiredMode = REQUIRED) + String grades, + + @Schema(name = "강의 이름", example = "한국사", requiredMode = REQUIRED) + String classTitle, + + @Schema(name = "분반", example = "01", requiredMode = NOT_REQUIRED) + String lectureClass, + + @Schema(name = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) + String target, + + @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) + String department + ) { + + public static List from(List timeTables) { + return timeTables.stream() + .map(it -> new InnerTimeTableResponse( + it.getId(), + it.getRegularNumber(), + it.getCode(), + it.getDesignScore(), + parseIntegerClassTimesFromString(it.getClassTime()), + it.getClassPlace(), + it.getMemo(), + it.getGrades(), + it.getClassTitle(), + it.getLectureClass(), + it.getTarget(), + it.getProfessor(), + it.getDepartment() + ) + ) + .toList(); + } + + } + + public static TimeTableResponse of(String semester, List timeTables, Integer grades, + Integer totalGrades) { + return new TimeTableResponse( + semester, + InnerTimeTableResponse.from(timeTables), + grades, + totalGrades + ); + } + + private static final int INITIAL_BRACE_INDEX = 1; + + private static List parseIntegerClassTimesFromString(String classTime) { + String classTimeWithoutBrackets = classTime.substring(INITIAL_BRACE_INDEX, classTime.length() - 1); + + if (!classTimeWithoutBrackets.isEmpty()) { + return Arrays.stream(classTimeWithoutBrackets.split(",")) + .map(String::strip) + .map(Integer::parseInt) + .toList(); + } else { + return Collections.emptyList(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableUpdateRequest.java new file mode 100644 index 000000000..be6c596f8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableUpdateRequest.java @@ -0,0 +1,82 @@ +package in.koreatech.koin.domain.timetable.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimeTableUpdateRequest( + @Valid + @Schema(description = "시간표 정보", requiredMode = NOT_REQUIRED) + @NotNull(message = "시간표 정보를 입력해주세요.") + List timetable, + + @Schema(description = "학기 정보", example = "20192", requiredMode = NOT_REQUIRED) + @NotBlank(message = "학기 정보를 입력해주세요.") + String semester +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerTimeTableRequest( + @Schema(description = "시간표 식별 번호", example = "1", requiredMode = REQUIRED) + @NotNull(message = "시간표 식별 번호를 입력해주세요.") + Integer id, + + @Schema(description = "과목 코드", example = "CPC490", requiredMode = NOT_REQUIRED) + String code, + + @Schema(description = "강의 이름", example = "운영체제", requiredMode = REQUIRED) + @NotBlank(message = "강의 이름을 입력해주세요.") + String classTitle, + + @Schema(description = "강의 시간", example = "[210, 211]", requiredMode = REQUIRED) + @NotNull(message = "강의 시간을 입력해주세요.") + List classTime, + + @Schema(description = "강의 장소", example = "null", requiredMode = NOT_REQUIRED) + String classPlace, + + @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(description = "대상 학년", example = "3", requiredMode = REQUIRED) + @NotBlank(message = "대상 학년을 입력해주세요.") + String grades, + + @Schema(name = "분반", example = "01", requiredMode = NOT_REQUIRED) + @Size(max = 3, message = "분반은 3자 이하로 입력해주세요.") + String lectureClass, + + @Schema(name = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) + @Size(max = 200, message = "대상은 200자 이하로 입력해주세요.") + String target, + + @Schema(name = "수강 인원", example = "25", requiredMode = NOT_REQUIRED) + @Size(max = 4, message = "수강 인원은 4자 이하로 입력해주세요.") + String regularNumber, + + @Schema(name = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) + @Size(max = 4, message = "설계 학점은 4자 이하로 입력해주세요.") + String designScore, + + @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) + @Size(max = 30, message = "학부는 30자 이하로 입력해주세요.") + String department, + + @Schema(name = "memo", example = "메모메모", requiredMode = NOT_REQUIRED) + @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") + String memo + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/exception/SemesterNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetable/exception/SemesterNotFoundException.java new file mode 100644 index 000000000..e5a6e8e53 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/exception/SemesterNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.timetable.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class SemesterNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 학기입니다."; + + public SemesterNotFoundException(String message) { + super(message); + } + + public SemesterNotFoundException(String message, String detail) { + super(message, detail); + } + + public static SemesterNotFoundException withDetail(String detail) { + return new SemesterNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/exception/TimeTableNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetable/exception/TimeTableNotFoundException.java new file mode 100644 index 000000000..e76c9a3e8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/exception/TimeTableNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.timetable.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class TimeTableNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 시간표입니다."; + + public TimeTableNotFoundException(String message) { + super(message); + } + + public TimeTableNotFoundException(String message, String detail) { + super(message, detail); + } + + public static TimeTableNotFoundException withDetail(String detail) { + return new TimeTableNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/model/Lecture.java b/src/main/java/in/koreatech/koin/domain/timetable/model/Lecture.java new file mode 100644 index 000000000..4966250c7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/model/Lecture.java @@ -0,0 +1,112 @@ +package in.koreatech.koin.domain.timetable.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "lectures") +@NoArgsConstructor(access = PROTECTED) +public class Lecture { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Size(max = 255) + @NotNull + @Column(name = "semester_date", nullable = false) + private String semester; + + @Size(max = 255) + @NotNull + @Column(name = "code", nullable = false) + private String code; + + @Size(max = 255) + @NotNull + @Column(name = "name", nullable = false) + private String name; + + @Size(max = 255) + @NotNull + @Column(name = "grades", nullable = false) + private String grades; + + @Size(max = 255) + @NotNull + @Column(name = "class", nullable = false) + private String lectureClass; + + @Size(max = 255) + @Column(name = "regular_number") + private String regularNumber; + + @Size(max = 255) + @NotNull + @Column(name = "department", nullable = false) + private String department; + + @Size(max = 255) + @NotNull + @Column(name = "target", nullable = false) + private String target; + + @Size(max = 255) + @Column(name = "professor") + private String professor; + + @Size(max = 255) + @Column(name = "is_english") + private String isEnglish; + + @Size(max = 255) + @NotNull + @Column(name = "design_score", nullable = false) + private String designScore; + + @Size(max = 255) + @NotNull + @Column(name = "is_elearning", nullable = false) + private String isElearning; + + @Size(max = 255) + @NotNull + @Column(name = "class_time", nullable = false) + private String classTime; + + @Builder + private Lecture( + String code, String semester, + String name, String grades, String lectureClass, + String regularNumber, String department, + String target, String professor, + String isEnglish, String designScore, + String isElearning, String classTime + ) { + this.code = code; + this.semester = semester; + this.name = name; + this.grades = grades; + this.lectureClass = lectureClass; + this.regularNumber = regularNumber; + this.department = department; + this.target = target; + this.professor = professor; + this.isEnglish = isEnglish; + this.designScore = designScore; + this.isElearning = isElearning; + this.classTime = classTime; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/model/Semester.java b/src/main/java/in/koreatech/koin/domain/timetable/model/Semester.java new file mode 100644 index 000000000..8f42c0a75 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/model/Semester.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.domain.timetable.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "semester") +@NoArgsConstructor(access = PROTECTED) +public class Semester { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Size(max = 10) + @NotNull + @Column(name = "semester", nullable = false, unique = true) + private String semester; + + @Builder + private Semester(String semester) { + this.semester = semester; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/model/TimeTable.java b/src/main/java/in/koreatech/koin/domain/timetable/model/TimeTable.java new file mode 100644 index 000000000..6f929fcb4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/model/TimeTable.java @@ -0,0 +1,140 @@ +package in.koreatech.koin.domain.timetable.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "timetables") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class TimeTable extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "semester_id", nullable = false) + private Semester semester; + + @Size(max = 10) + @Column(name = "code", length = 10) + private String code; + + @NotNull + @Size(max = 50) + @Column(name = "class_title", nullable = false, length = 50) + private String classTitle; + + @NotNull + @Size(max = 100) + @Column(name = "class_time", nullable = false, length = 100) + private String classTime; + + @Size(max = 30) + @Column(name = "class_place", length = 30) + private String classPlace; + + @Size(max = 30) + @Column(name = "professor", length = 30) + private String professor; + + @NotNull + @Size(max = 2) + @Column(name = "grades", nullable = false, length = 2) + private String grades; + + @Size(max = 3) + @Column(name = "lecture_class", length = 3) + private String lectureClass; + + @Size(max = 200) + @Column(name = "target", length = 200) + private String target; + + @Size(max = 4) + @Column(name = "regular_number", length = 4) + private String regularNumber; + + @Size(max = 4) + @Column(name = "design_score", length = 4) + private String designScore; + + @Size(max = 30) + @Column(name = "department", length = 30) + private String department; + + @Size(max = 200) + @Column(name = "memo", length = 200) + private String memo; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Builder + private TimeTable(User user, Semester semester, String code, String classTitle, String classTime, + String classPlace, String professor, String grades, String lectureClass, String target, + String regularNumber, + String designScore, String department, String memo, boolean isDeleted) { + this.user = user; + this.semester = semester; + this.code = code; + this.classTitle = classTitle; + this.classTime = classTime; + this.classPlace = classPlace; + this.professor = professor; + this.grades = grades; + this.lectureClass = lectureClass; + this.target = target; + this.regularNumber = regularNumber; + this.designScore = designScore; + this.department = department; + this.memo = memo; + this.isDeleted = isDeleted; + } + + public void update(TimeTableUpdateRequest.InnerTimeTableRequest timeTableRequest) { + this.code = timeTableRequest.code(); + this.classTitle = timeTableRequest.classTitle(); + this.classTime = timeTableRequest.classTime().toString(); + this.classPlace = timeTableRequest.classPlace(); + this.professor = timeTableRequest.professor(); + this.grades = timeTableRequest.grades(); + this.lectureClass = timeTableRequest.lectureClass(); + this.target = timeTableRequest.target(); + this.regularNumber = timeTableRequest.regularNumber(); + this.designScore = timeTableRequest.designScore(); + this.department = timeTableRequest.department(); + this.memo = timeTableRequest.memo(); + this.isDeleted = false; + } + + public void updateIsDeleted(boolean isDeleted) { + this.isDeleted = isDeleted; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java new file mode 100644 index 000000000..65c0b708b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.timetable.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.model.Lecture; + +public interface LectureRepository extends Repository { + + List findBySemester(String semesterDate); + + Lecture save(Lecture lecture); +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/SemesterRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/SemesterRepository.java new file mode 100644 index 000000000..926096f4b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/repository/SemesterRepository.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.timetable.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; +import in.koreatech.koin.domain.timetable.model.Semester; + +public interface SemesterRepository extends Repository { + + List findAllByOrderBySemesterDesc(); + + Semester save(Semester semester); + + Optional findBySemester(String semester); + + default Semester getBySemester(String semester) { + return findBySemester(semester) + .orElseThrow(() -> SemesterNotFoundException.withDetail("semester: " + semester)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java new file mode 100644 index 000000000..94da1a231 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.timetable.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.exception.TimeTableNotFoundException; +import in.koreatech.koin.domain.timetable.model.TimeTable; + +public interface TimeTableRepository extends Repository { + + TimeTable save(TimeTable timeTable); + + List findAllByUserIdAndSemesterId(Integer userId, Integer semesterId); + + Optional findById(Integer id); + + void deleteByUserIdAndSemesterId(Integer userId, Integer semesterId); + + default TimeTable getById(Integer id) { + return findById(id) + .orElseThrow(() -> TimeTableNotFoundException.withDetail("id: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java b/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java new file mode 100644 index 000000000..887b12042 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.timetable.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.timetable.dto.SemesterResponse; +import in.koreatech.koin.domain.timetable.repository.SemesterRepository; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class SemesterService { + + private final SemesterRepository semesterRepository; + + public List getSemesters() { + return semesterRepository.findAllByOrderBySemesterDesc().stream() + .map(SemesterResponse::from) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java b/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java new file mode 100644 index 000000000..bc222c4a4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java @@ -0,0 +1,97 @@ +package in.koreatech.koin.domain.timetable.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.timetable.dto.LectureResponse; +import in.koreatech.koin.domain.timetable.dto.TimeTableCreateRequest; +import in.koreatech.koin.domain.timetable.dto.TimeTableResponse; +import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetable.model.TimeTable; +import in.koreatech.koin.domain.timetable.repository.LectureRepository; +import in.koreatech.koin.domain.timetable.repository.SemesterRepository; +import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TimetableService { + + private final LectureRepository lectureRepository; + private final SemesterRepository semesterRepository; + private final TimeTableRepository timeTableRepository; + private final UserRepository userRepository; + + public List getLecturesBySemester(String semester) { + List lectures = lectureRepository.findBySemester(semester); + if (lectures.isEmpty()) { + throw SemesterNotFoundException.withDetail(semester); + } + return lectures.stream() + .map(LectureResponse::from) + .toList(); + } + + public TimeTableResponse getTimeTables(Integer userId, String semesterRequest) { + Semester semester = semesterRepository.getBySemester(semesterRequest); + return getTimeTableResponse(userId, semester); + } + + @Transactional + public TimeTableResponse createTimeTables(Integer userId, TimeTableCreateRequest request) { + User user = userRepository.getById(userId); + Semester semester = semesterRepository.getBySemester(request.semester()); + for (TimeTableCreateRequest.InnerTimeTableRequest timeTableRequest : request.timetable()) { + TimeTable timeTable = timeTableRequest.toTimeTable(user, semester); + timeTableRepository.save(timeTable); + } + return getTimeTableResponse(userId, semester); + } + + @Transactional + public TimeTableResponse updateTimeTables(Integer userId, TimeTableUpdateRequest request) { + Semester semester = semesterRepository.getBySemester(request.semester()); + for (TimeTableUpdateRequest.InnerTimeTableRequest timeTableRequest : request.timetable()) { + TimeTable timeTable = timeTableRepository.getById(timeTableRequest.id()); + timeTable.update(timeTableRequest); + } + return getTimeTableResponse(userId, semester); + } + + @Transactional + public void deleteTimeTable(Integer id) { + TimeTable timeTable = timeTableRepository.getById(id); + timeTable.updateIsDeleted(true); + } + + private TimeTableResponse getTimeTableResponse(Integer userId, Semester semester) { + List timeTables = timeTableRepository.findAllByUserIdAndSemesterId(userId, semester.getId()); + Integer grades = timeTables.stream() + .mapToInt(timeTable -> Integer.parseInt(timeTable.getGrades())) + .sum(); + Integer totalGrades = calculateTotalGrades(userId); + + return TimeTableResponse.of(semester.getSemester(), timeTables, grades, totalGrades); + } + + private int calculateTotalGrades(Integer userId) { + int totalGrades = 0; + List semesters = semesterRepository.findAllByOrderBySemesterDesc(); + + for (Semester semester : semesters) { + totalGrades += timeTableRepository.findAllByUserIdAndSemesterId(userId, semester.getId()).stream() + .mapToInt(timeTable -> Integer.parseInt(timeTable.getGrades())) + .sum(); + } + + return totalGrades; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java new file mode 100644 index 000000000..3350501cb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java @@ -0,0 +1,239 @@ +package in.koreatech.koin.domain.user.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COOP; +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +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.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import in.koreatech.koin.domain.user.dto.AuthResponse; +import in.koreatech.koin.domain.user.dto.CoopResponse; +import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; +import in.koreatech.koin.domain.user.dto.FindPasswordRequest; +import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest; +import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; +import in.koreatech.koin.domain.user.dto.StudentResponse; +import in.koreatech.koin.domain.user.dto.StudentUpdateRequest; +import in.koreatech.koin.domain.user.dto.StudentUpdateResponse; +import in.koreatech.koin.domain.user.dto.UserLoginRequest; +import in.koreatech.koin.domain.user.dto.UserLoginResponse; +import in.koreatech.koin.domain.user.dto.UserPasswordCheckRequest; +import in.koreatech.koin.domain.user.dto.UserTokenRefreshRequest; +import in.koreatech.koin.domain.user.dto.UserTokenRefreshResponse; +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.host.ServerURL; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) User: 회원", description = "회원 관련 API") +public interface UserApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원 정보 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/user/student/me") + ResponseEntity getStudent( + @Auth(permit = STUDENT) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "영양사 정보 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/user/coop/me") + ResponseEntity getCoop( + @Auth(permit = COOP) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "회원 정보 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/user/student/me") + ResponseEntity updateStudent( + @Auth(permit = STUDENT) Integer userId, + @Valid StudentUpdateRequest studentUpdateRequest + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "로그인") + @PostMapping("/user/login") + ResponseEntity login( + @RequestBody @Valid UserLoginRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "로그아웃") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/user/logout") + ResponseEntity logout( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "토큰 갱신") + @PostMapping("/user/refresh") + ResponseEntity refresh( + @RequestBody @Valid UserTokenRefreshRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원가입") + @PostMapping("/user/student/register") + ResponseEntity studentRegister( + @RequestBody @Valid StudentRegisterRequest studentRegisterRequest, + @ServerURL String serverURL + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원 탈퇴") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/user") + ResponseEntity withdraw( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "이메일 중복 체크") + @GetMapping("/user/check/email") + ResponseEntity checkUserEmailExist( + @ModelAttribute("address") + @Valid EmailCheckExistsRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "닉네임 중복 체크") + @GetMapping("/user/check/nickname") + ResponseEntity checkDuplicationOfNickname( + @ModelAttribute("nickname") + @Valid NicknameCheckExistsRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "사용자 권한 조회") + @GetMapping("/user/auth") + ResponseEntity getAuth( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "비밀번호 초기(변경) 메일 발송") + @PostMapping("/user/find/password") + ResponseEntity findPassword( + @RequestBody @Valid FindPasswordRequest findPasswordRequest, + @ServerURL String serverURL + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "비밀번호 검증") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/user/check/password") + ResponseEntity checkPassword( + @Valid @RequestBody UserPasswordCheckRequest request, + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java new file mode 100644 index 000000000..6c12e3e2a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java @@ -0,0 +1,189 @@ +package in.koreatech.koin.domain.user.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COOP; +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.net.URI; + +import org.springframework.http.HttpStatusCode; +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.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.ModelAndView; + +import in.koreatech.koin.domain.user.dto.AuthResponse; +import in.koreatech.koin.domain.user.dto.AuthTokenRequest; +import in.koreatech.koin.domain.user.dto.CoopResponse; +import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; +import in.koreatech.koin.domain.user.dto.FindPasswordRequest; +import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest; +import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; +import in.koreatech.koin.domain.user.dto.StudentResponse; +import in.koreatech.koin.domain.user.dto.StudentUpdateRequest; +import in.koreatech.koin.domain.user.dto.StudentUpdateResponse; +import in.koreatech.koin.domain.user.dto.UserLoginRequest; +import in.koreatech.koin.domain.user.dto.UserLoginResponse; +import in.koreatech.koin.domain.user.dto.UserPasswordChangeRequest; +import in.koreatech.koin.domain.user.dto.UserPasswordCheckRequest; +import in.koreatech.koin.domain.user.dto.UserTokenRefreshRequest; +import in.koreatech.koin.domain.user.dto.UserTokenRefreshResponse; +import in.koreatech.koin.domain.user.service.StudentService; +import in.koreatech.koin.domain.user.service.UserService; +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.host.ServerURL; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class UserController implements UserApi { + + private final UserService userService; + private final StudentService studentService; + + @GetMapping("/user/student/me") + public ResponseEntity getStudent( + @Auth(permit = STUDENT) Integer userId + ) { + StudentResponse studentResponse = studentService.getStudent(userId); + return ResponseEntity.ok().body(studentResponse); + } + + @GetMapping("/user/coop/me") + public ResponseEntity getCoop( + @Auth(permit = COOP) Integer userId + ) { + CoopResponse coopResponse = userService.getCoop(userId); + return ResponseEntity.ok().body(coopResponse); + } + + @PutMapping("/user/student/me") + public ResponseEntity updateStudent( + @Auth(permit = STUDENT) Integer userId, + @Valid @RequestBody StudentUpdateRequest request + ) { + StudentUpdateResponse studentUpdateResponse = studentService.updateStudent(userId, request); + return ResponseEntity.ok(studentUpdateResponse); + } + + @PostMapping("/user/login") + public ResponseEntity login( + @RequestBody @Valid UserLoginRequest request + ) { + UserLoginResponse response = userService.login(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } + + @PostMapping("/user/logout") + public ResponseEntity logout( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ) { + userService.logout(userId); + return ResponseEntity.ok().build(); + } + + @PostMapping("/user/refresh") + public ResponseEntity refresh( + @RequestBody @Valid UserTokenRefreshRequest request + ) { + UserTokenRefreshResponse tokenGroupResponse = userService.refresh(request); + return ResponseEntity.created(URI.create("/")) + .body(tokenGroupResponse); + } + + @DeleteMapping("/user") + public ResponseEntity withdraw( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ) { + userService.withdraw(userId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/user/check/email") + public ResponseEntity checkUserEmailExist( + @ModelAttribute(value = "address") + @Valid EmailCheckExistsRequest request + ) { + userService.checkExistsEmail(request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/user/student/register") + public ResponseEntity studentRegister( + @Valid @RequestBody StudentRegisterRequest request, + @ServerURL String serverURL + ) { + studentService.studentRegister(request, serverURL); + return ResponseEntity.ok().build(); + } + + @GetMapping(value = "/user/authenticate") + public ModelAndView authenticate( + @ModelAttribute("auth_token") + @Valid AuthTokenRequest request + ) { + return studentService.authenticate(request); + } + + @GetMapping("/user/check/nickname") + public ResponseEntity checkDuplicationOfNickname( + @ModelAttribute("nickname") + @Valid NicknameCheckExistsRequest request + ) { + userService.checkUserNickname(request); + return ResponseEntity.ok().build(); + } + + @GetMapping("/user/auth") + public ResponseEntity getAuth( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ) { + AuthResponse authResponse = userService.getAuth(userId); + return ResponseEntity.ok().body(authResponse); + } + + @PostMapping("/user/find/password") + public ResponseEntity findPassword( + @RequestBody @Valid FindPasswordRequest request, + @ServerURL String serverURL + ) { + studentService.findPassword(request, serverURL); + return new ResponseEntity<>(HttpStatusCode.valueOf(201)); + } + + @PostMapping("/user/check/password") + public ResponseEntity checkPassword( + @Valid @RequestBody UserPasswordCheckRequest request, + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ) { + userService.checkPassword(request, userId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/user/change/password/config") + public ModelAndView checkResetToken( + @ServerURL String serverUrl, + @RequestParam("reset_token") String resetToken + ) { + return studentService.checkResetToken(resetToken, serverUrl); + } + + @Hidden + @PostMapping("/user/change/password/submit") + public ResponseEntity changePassword( + @RequestBody UserPasswordChangeRequest request, + @RequestParam("reset_token") String resetToken + ) { + studentService.changePassword(request, resetToken); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/AuthResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/AuthResponse.java new file mode 100644 index 000000000..796aaba0f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/AuthResponse.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AuthResponse( + @Schema(description = "사용자 권한 타입", example = "STUDENT", requiredMode = REQUIRED) + String userType +) { + + public static AuthResponse from(User user) { + return new AuthResponse(user.getUserType().getValue()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/AuthTokenRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/AuthTokenRequest.java new file mode 100644 index 000000000..df7cdff6b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/AuthTokenRequest.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.user.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AuthTokenRequest( + @Schema(description = "인증토큰", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0QGdtYWlsLmNvbSJ", requiredMode = REQUIRED) + @NotBlank(message = "토큰은 필수입니다.") + String authToken +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/CoopResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/CoopResponse.java new file mode 100644 index 000000000..0fb191fe2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/CoopResponse.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CoopResponse( + @Schema(description = "이메일 주소", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) + String email, + + @Schema(description = "성별(남:0, 여:1)", example = "1", requiredMode = NOT_REQUIRED) + Integer gender, + + @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + String name, + + @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + String phoneNumber, + + @Schema(description = "유저 타입", example = "COOP", requiredMode = REQUIRED) + String userType +) { + + public static CoopResponse from(User user) { + Integer userGender = null; + if (user.getGender() != null) { + userGender = user.getGender().ordinal(); + } + return new CoopResponse( + user.getEmail(), + userGender, + user.getName(), + user.getPhoneNumber(), + user.getUserType().getValue() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/EmailCheckExistsRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/EmailCheckExistsRequest.java new file mode 100644 index 000000000..754333c2a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/EmailCheckExistsRequest.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record EmailCheckExistsRequest( + @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) + @Email(message = "이메일 형식이 올바르지 않습니다. ${validatedValue}") + @NotBlank(message = "이메일을 입력해주세요.") + String email +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/FindPasswordRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/FindPasswordRequest.java new file mode 100644 index 000000000..98b5673bb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/FindPasswordRequest.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +public record FindPasswordRequest( + @Schema(description = "이메일 주소", example = "asdf@koreatech.ac.kr", requiredMode = REQUIRED) + @Pattern(regexp = "^[a-zA-Z0-9._%+-]+@koreatech.ac.kr$", message = "아우누리 계정 형식이 아닙니다. ${validatedValue}") + @NotNull(message = "이메일을 입력해주세요.") + String email +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/NicknameCheckExistsRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/NicknameCheckExistsRequest.java new file mode 100644 index 000000000..a2d1c76e2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/NicknameCheckExistsRequest.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record NicknameCheckExistsRequest( + @Schema(description = "닉네임", example = "홍길동", requiredMode = REQUIRED) + @Size(max = 10, message = "닉네임은 최대 10자입니다.") + @NotBlank(message = "닉네임을 입력해주세요.") + String nickname +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java new file mode 100644 index 000000000..d995a4d68 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java @@ -0,0 +1,105 @@ +package in.koreatech.koin.domain.user.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.security.crypto.password.PasswordEncoder; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.user.model.Student; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserGender; +import in.koreatech.koin.domain.user.model.UserIdentity; +import in.koreatech.koin.domain.user.model.UserType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record StudentRegisterRequest( + @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) + @Email(message = "이메일 형식을 지켜주세요. ${validatedValue}") + @NotBlank(message = "이메일을 입력해주세요.") + String email, + + @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + @Size(max = 50, message = "이름은 50자 이내여야 합니다.") + String name, + + @Schema(description = " SHA 256 해시 알고리즘으로 암호화된 비밀번호", example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", requiredMode = REQUIRED) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password, + + @Schema(description = "닉네임", example = "bbo", requiredMode = NOT_REQUIRED) + @Size(max = 10, message = "닉네임은 최대 10자입니다.") + String nickname, + + @Schema(description = "성별(남:0, 여:1)", example = "0", requiredMode = NOT_REQUIRED) + UserGender gender, + + @Schema(description = "졸업 여부", example = "false", requiredMode = NOT_REQUIRED) + boolean isGraduated, + + @Schema( + description = """ + - 전공 + - 기계공학부 + - 컴퓨터공학부 + - 메카트로닉스공학부 + - 전기전자통신공학부 + - 디자인공학부 + - 건축공학부 + - 화학생명공학부 + - 에너지신소재공학부 + - 산업경영학부 + - 고용서비스정책학과 + """, + example = "컴퓨터공학부", + requiredMode = REQUIRED + ) + @JsonProperty("major") + String department, + + @Schema(description = "학번", example = "2021136012", requiredMode = NOT_REQUIRED) + @Size(min = 10, max = 10, message = "학번은 10자여야합니다.") + String studentNumber, + + @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}", message = "전화번호 형식이 올바르지 않습니다.") + String phoneNumber +) { + + public Student toStudent(PasswordEncoder passwordEncoder, Clock clock) { + User user = User.builder() + .password(passwordEncoder.encode(password)) + .email(email) + .name(name) + .nickname(nickname) + .gender(gender) + .phoneNumber(phoneNumber) + .isAuthed(false) + .isDeleted(false) + .userType(UserType.STUDENT) + .authToken(UUID.randomUUID().toString()) + .authExpiredAt(LocalDateTime.now(clock).plusHours(10)) + .build(); + + return Student.builder() + .user(user) + .anonymousNickname("익명_" + (System.currentTimeMillis())) + .isGraduated(isGraduated) + .userIdentity(UserIdentity.UNDERGRADUATE) + .department(department) + .studentNumber(studentNumber) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentResponse.java new file mode 100644 index 000000000..f5b0c56f5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentResponse.java @@ -0,0 +1,68 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.user.model.Student; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record StudentResponse( + @Schema(description = "익명 닉네임", example = "익명_1676688416361", requiredMode = NOT_REQUIRED) + String anonymousNickname, + + @Schema(description = "이메일 주소", example = "koin123@koreatech.ac.kr", requiredMode = NOT_REQUIRED) + String email, + + @Schema(description = "성별(남:0, 여:1)", example = "1", requiredMode = NOT_REQUIRED) + Integer gender, + + @Schema(description = """ + 전공 + - 기계공학부 + - 컴퓨터공학부 + - 메카트로닉스공학부 + - 전기전자통신공학부 + - 디자인공학부 + - 건축공학부 + - 화학생명공학부 + - 에너지신소재공학부 + - 산업경영학부 + - 고용서비스정책학부 + """, example = "컴퓨터공학부", requiredMode = NOT_REQUIRED) + String major, + + @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + String name, + + @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) + String nickname, + + @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + String phoneNumber, + + @Schema(description = "학번", example = "2029136012", requiredMode = NOT_REQUIRED) + String studentNumber +) { + + public static StudentResponse from(Student student) { + User user = student.getUser(); + Integer userGender = null; + if (user.getGender() != null) { + userGender = user.getGender().ordinal(); + } + return new StudentResponse( + student.getAnonymousNickname(), + user.getEmail(), + userGender, + student.getDepartment(), + user.getName(), + user.getNickname(), + user.getPhoneNumber(), + student.getStudentNumber() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java new file mode 100644 index 000000000..b2ce2c7e3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java @@ -0,0 +1,58 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record StudentUpdateRequest + ( + @Schema(description = "성별(남:0, 여:1)", example = "1", requiredMode = NOT_REQUIRED) + Integer gender, + + @Schema(description = "[NOT UPDATE]신원(학생, 사장님)", example = "학생", requiredMode = NOT_REQUIRED) + Integer userIdentity, + + @Schema(description = "[NOT UPDATE]졸업 여부(true, false)", example = "false", requiredMode = NOT_REQUIRED) + Boolean isGraduated, + + @Schema(description = """ + 전공 + - 기계공학부 + - 컴퓨터공학부 + - 메카트로닉스공학부 + - 전기전자통신공학부 + - 디자인공학부 + - 건축공학부 + - 화학생명공학부 + - 에너지신소재공학부 + - 산업경영학부 + - 고용서비스정책학부 + """, example = "컴퓨터공학부", requiredMode = NOT_REQUIRED) + String major, + + @Size(max = 50, message = "이름의 길이는 최대 50자 입니다.") + @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + String name, + + @Size(message = "SHA 256 해시 알고리즘으로 암호화 된 비밀번호") + @Schema(description = "비밀번호", example = "a0240120305812krlakdsflsa;1235", requiredMode = NOT_REQUIRED) + String password, + + @Size(max = 10, message = "닉네임은 10자 이내여야 합니다.") + @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) + String nickname, + + @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + String phoneNumber, + + @Size(min = 10, max = 10, message = "학번은 10자여야 합니다.") + @Schema(description = "학번", example = "2020136065", requiredMode = NOT_REQUIRED) + String studentNumber + ) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java new file mode 100644 index 000000000..12d995f7a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java @@ -0,0 +1,66 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.user.model.Student; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record StudentUpdateResponse( + @Schema(description = "익명 닉네임", example = "익명_1676688416361", requiredMode = NOT_REQUIRED) + String anonymousNickname, + + @Schema(description = "이메일 주소", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) + String email, + + @Schema(description = "성별(남:0, 여:1)", example = "1", requiredMode = NOT_REQUIRED) + Integer gender, + + @Schema(description = """ + 전공 + - 기계공학부 + - 컴퓨터공학부 + - 메카트로닉스공학부 + - 전기전자통신공학부 + - 디자인공학부 + - 건축공학부 + - 화학생명공학부 + - 에너지신소재공학부 + - 산업경영학부 + - 고용서비스정책학부 + """, example = "컴퓨터공학부", requiredMode = NOT_REQUIRED) + String major, + + @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + String name, + + @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) + String nickname, + + @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + String phoneNumber, + + @Schema(description = "학번", example = "2029136012", requiredMode = NOT_REQUIRED) + String studentNumber +) { + + public static StudentUpdateResponse from(Student student) { + User user = student.getUser(); + Integer userGender = user.getGender() != null ? user.getGender().ordinal() : null; + return new StudentUpdateResponse( + student.getAnonymousNickname(), + user.getEmail(), + userGender, + student.getDepartment(), + user.getName(), + user.getNickname(), + user.getPhoneNumber(), + student.getStudentNumber() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginRequest.java new file mode 100644 index 000000000..34759c392 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginRequest.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record UserLoginRequest( + @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) + @Email(message = "이메일 형식을 지켜주세요. ${validatedValue}") + @NotBlank(message = "이메일을 입력해주세요.") + String email, + + @Schema( + description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", + example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + requiredMode = REQUIRED + ) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java new file mode 100644 index 000000000..f72752527 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserLoginResponse( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refresh token", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken, + + @Schema( + description = """ + 로그인한 회원의 신원 + - `STUDENT`: 학생 + - `OWNER`: 사장님 + """, example = "STUDENT", requiredMode = REQUIRED + ) + @JsonProperty("user_type") + String userType +) { + + public static UserLoginResponse of(String token, String refreshToken, String userType) { + return new UserLoginResponse(token, refreshToken, userType); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserPasswordChangeRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserPasswordChangeRequest.java new file mode 100644 index 000000000..aa7d338bd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserPasswordChangeRequest.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@JsonNaming(SnakeCaseStrategy.class) +public record UserPasswordChangeRequest( + @Schema(description = "변경할 비밀번호 (SHA 256 해싱된 값)", example = "password", requiredMode = REQUIRED) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserPasswordCheckRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserPasswordCheckRequest.java new file mode 100644 index 000000000..a2df30453 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserPasswordCheckRequest.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@JsonNaming(SnakeCaseStrategy.class) +public record UserPasswordCheckRequest( + @Schema( + description = "확인할 비밀번호 (SHA 256 해싱된 값)", + example = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", + requiredMode = REQUIRED) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserTokenRefreshRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserTokenRefreshRequest.java new file mode 100644 index 000000000..8b8279d04 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserTokenRefreshRequest.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record UserTokenRefreshRequest( + @Schema(description = "refresh_token", example = "eyJhbGciOiJIUzI1NiJ9", requiredMode = REQUIRED) + @NotNull(message = "refresh_token을 입력해주세요.") + @JsonProperty("refresh_token") + String refreshToken +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserTokenRefreshResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserTokenRefreshResponse.java new file mode 100644 index 000000000..3f9a6dcc9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserTokenRefreshResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserTokenRefreshResponse( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refreshToken", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken +) { + + public static UserTokenRefreshResponse of(String accessToken, String refreshToken) { + return new UserTokenRefreshResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/exception/DuplicationNicknameException.java b/src/main/java/in/koreatech/koin/domain/user/exception/DuplicationNicknameException.java new file mode 100644 index 000000000..ee5624ebd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/exception/DuplicationNicknameException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.user.exception; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class DuplicationNicknameException extends DuplicationException { + + private static final String DEFAULT_MESSAGE = "이미 존재하는 닉네임입니다."; + + public DuplicationNicknameException(String message) { + super(message); + } + + public DuplicationNicknameException(String message, String detail) { + super(message, detail); + } + + public static DuplicationNicknameException withDetail(String detail) { + return new DuplicationNicknameException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/exception/StudentDepartmentNotValidException.java b/src/main/java/in/koreatech/koin/domain/user/exception/StudentDepartmentNotValidException.java new file mode 100644 index 000000000..9b1247509 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/exception/StudentDepartmentNotValidException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.user.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class StudentDepartmentNotValidException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "학생의 전공 형식이 아닙니다."; + + public StudentDepartmentNotValidException(String message) { + super(message); + } + + public StudentDepartmentNotValidException(String message, String detail) { + super(message, detail); + } + + public static StudentDepartmentNotValidException withDetail(String detail) { + return new StudentDepartmentNotValidException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/exception/StudentNumberNotValidException.java b/src/main/java/in/koreatech/koin/domain/user/exception/StudentNumberNotValidException.java new file mode 100644 index 000000000..be7e1acb1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/exception/StudentNumberNotValidException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.user.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class StudentNumberNotValidException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "학생의 학번 형식이 아닙니다."; + + public StudentNumberNotValidException(String message) { + super(message); + } + + public StudentNumberNotValidException(String message, String detail) { + super(message, detail); + } + + public static StudentNumberNotValidException withDetail(String detail) { + return new StudentNumberNotValidException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/exception/UserGenderNotValidException.java b/src/main/java/in/koreatech/koin/domain/user/exception/UserGenderNotValidException.java new file mode 100644 index 000000000..aab98bcf2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/exception/UserGenderNotValidException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.user.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class UserGenderNotValidException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "잘못된 성별 인덱스입니다."; + + public UserGenderNotValidException(String message) { + super(message); + } + + public UserGenderNotValidException(String message, String detail) { + super(message, detail); + } + + public static UserGenderNotValidException withDetail(String detail) { + return new UserGenderNotValidException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/exception/UserNotFoundException.java b/src/main/java/in/koreatech/koin/domain/user/exception/UserNotFoundException.java new file mode 100644 index 000000000..87baa9bb9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/exception/UserNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.user.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class UserNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 사용자입니다."; + + public UserNotFoundException(String message) { + super(message); + } + + public UserNotFoundException(String message, String detail) { + super(message, detail); + } + + public static UserNotFoundException withDetail(String detail) { + return new UserNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/exception/UserResetTokenExpiredException.java b/src/main/java/in/koreatech/koin/domain/user/exception/UserResetTokenExpiredException.java new file mode 100644 index 000000000..03292677c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/exception/UserResetTokenExpiredException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.user.exception; + +import in.koreatech.koin.global.auth.exception.AuthenticationException; + +public class UserResetTokenExpiredException extends AuthenticationException { + + private static final String DEFAULT_MESSAGE = "비밀번호 재설정 토큰이 만료되었습니다."; + + public UserResetTokenExpiredException(String message) { + super(message); + } + + public UserResetTokenExpiredException(String message, String detail) { + super(message, detail); + } + + public static UserResetTokenExpiredException withDetail(String detail) { + return new UserResetTokenExpiredException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/AuthResult.java b/src/main/java/in/koreatech/koin/domain/user/model/AuthResult.java new file mode 100644 index 000000000..2b94b7180 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/AuthResult.java @@ -0,0 +1,45 @@ +package in.koreatech.koin.domain.user.model; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.web.servlet.ModelAndView; + +public class AuthResult { + + private final Optional user; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + public AuthResult(Optional user, ApplicationEventPublisher eventPublisher, Clock clock) { + this.user = user; + this.eventPublisher = eventPublisher; + this.clock = clock; + } + + public ModelAndView toModelAndViewForStudent() { + return user.map(user -> { + if (user.getAuthExpiredAt().isBefore(LocalDateTime.now(clock))) { + return createErrorModelAndView("이미 만료된 토큰입니다."); + } + if (!user.isAuthed()) { + user.auth(); + eventPublisher.publishEvent(new StudentRegisterEvent(user.getEmail())); + return createSuccessModelAndView(); + } + return createErrorModelAndView("이미 인증된 사용자입니다."); + }).orElseGet(() -> createErrorModelAndView("토큰에 해당하는 사용자를 찾을 수 없습니다.")); + } + + private ModelAndView createErrorModelAndView(String errorMessage) { + ModelAndView modelAndView = new ModelAndView("error_config"); + modelAndView.addObject("errorMessage", errorMessage); + return modelAndView; + } + + private ModelAndView createSuccessModelAndView() { + return new ModelAndView("success_register_config"); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/Student.java b/src/main/java/in/koreatech/koin/domain/user/model/Student.java new file mode 100644 index 000000000..31e48cc5f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/Student.java @@ -0,0 +1,75 @@ +package in.koreatech.koin.domain.user.model; + +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "students") +@NoArgsConstructor(access = PROTECTED) +public class Student { + + @Id + @Column(name = "user_id") + private Integer id; + + @Size(max = 255) + @Column(name = "anonymous_nickname", unique = true) + private String anonymousNickname = "익명_" + System.currentTimeMillis(); + + @Size(max = 20) + @Column(name = "student_number", length = 20) + private String studentNumber; + + @Column(name = "major", length = 50) + private String department; + + @Column(name = "identity", columnDefinition = "SMALLINT") + @Enumerated(EnumType.ORDINAL) + private UserIdentity userIdentity; + + @Column(name = "is_graduated") + private boolean isGraduated; + + @OneToOne + @MapsId + private User user; + + @Builder + private Student( + String anonymousNickname, + String studentNumber, + String department, + UserIdentity userIdentity, + boolean isGraduated, + User user + ) { + this.anonymousNickname = anonymousNickname; + this.studentNumber = studentNumber; + this.department = department; + this.userIdentity = userIdentity; + this.isGraduated = isGraduated; + this.user = user; + } + + public void update(String studentNumber, String department) { + this.studentNumber = studentNumber; + this.department = department; + } + + public static Integer parseStudentNumberYear(String studentNumber) { + return Integer.parseInt(studentNumber.substring(0, 4)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/StudentDepartment.java b/src/main/java/in/koreatech/koin/domain/user/model/StudentDepartment.java new file mode 100644 index 000000000..89fb73409 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/StudentDepartment.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.domain.user.model; + +import lombok.Getter; + +@Getter +public enum StudentDepartment { + COMPUTER("컴퓨터공학부"), + MECHANICAL("기계공학부"), + MECHATRONICS("메카트로닉스공학부"), + ELECTRONIC("전기전자통신공학부"), + DESIGN("디자인공학부"), + ARCHITECTURAL("건축공학부"), + CHEMICAL("화학생명공학부"), + ENERGY("에너지신소재공학부"), + INDUSTRIAL("산업경영학부"), + EMPLOYMENT("고용서비스정책학과"), + ; + + private final String value; + + StudentDepartment(String value) { + this.value = value; + } + + public static boolean isValid(String department) { + for (StudentDepartment value : StudentDepartment.values()) { + if (value.getValue().equals(department)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/StudentEmailRequestEvent.java b/src/main/java/in/koreatech/koin/domain/user/model/StudentEmailRequestEvent.java new file mode 100644 index 000000000..7a8eb907d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/StudentEmailRequestEvent.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.user.model; + +public record StudentEmailRequestEvent( + String email +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/StudentEventListener.java b/src/main/java/in/koreatech/koin/domain/user/model/StudentEventListener.java new file mode 100644 index 000000000..243f5a0ac --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/StudentEventListener.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.domain.user.model; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import in.koreatech.koin.global.domain.slack.SlackClient; +import in.koreatech.koin.global.domain.slack.model.SlackNotificationFactory; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional(propagation = Propagation.REQUIRES_NEW) +public class StudentEventListener { + + private final SlackClient slackClient; + private final SlackNotificationFactory slackNotificationFactory; + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onStudentEmailRequest(StudentEmailRequestEvent event) { + var notification = slackNotificationFactory.generateStudentEmailVerificationRequestNotification(event.email()); + slackClient.sendMessage(notification); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onStudentRegister(StudentRegisterEvent event) { + var notification = slackNotificationFactory.generateStudentRegisterCompleteNotification(event.email()); + slackClient.sendMessage(notification); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/StudentRegisterEvent.java b/src/main/java/in/koreatech/koin/domain/user/model/StudentRegisterEvent.java new file mode 100644 index 000000000..3f4cbc889 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/StudentRegisterEvent.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.user.model; + +public record StudentRegisterEvent( + String email +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java new file mode 100644 index 000000000..c86c0f513 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -0,0 +1,187 @@ +package in.koreatech.koin.domain.user.model; + +import static lombok.AccessLevel.PROTECTED; + +import java.time.Clock; +import java.time.LocalDateTime; + +import org.hibernate.annotations.Where; +import org.springframework.security.crypto.password.PasswordEncoder; + +import in.koreatech.koin.domain.user.exception.UserResetTokenExpiredException; +import in.koreatech.koin.global.config.LocalDateTimeAttributeConverter; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "users") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @NotNull + @Column(name = "password", nullable = false) + private String password; + + @Size(max = 50) + @Column(name = "nickname", length = 50, unique = true) + private String nickname; + + @Size(max = 50) + @Column(name = "name", length = 50, unique = true) + private String name; + + @Size(max = 20) + @Column(name = "phone_number", length = 20) + private String phoneNumber; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "user_type", nullable = false, length = 20) + private UserType userType; + + @Size(max = 100) + @NotNull + @Column(name = "email", nullable = false, length = 100) + private String email; + + @Column(name = "gender", columnDefinition = "INT") + @Enumerated(value = EnumType.ORDINAL) + private UserGender gender; + + @NotNull + @Column(name = "is_authed", nullable = false) + private boolean isAuthed = false; + + @Column(name = "last_logged_at", columnDefinition = "TIMESTAMP") + private LocalDateTime lastLoggedAt; + + @Size(max = 255) + @Column(name = "profile_image_url") + private String profileImageUrl; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Size(max = 255) + @Column(name = "auth_token") + private String authToken; + + @Convert(converter = LocalDateTimeAttributeConverter.class) + @Column(name = "auth_expired_at", columnDefinition = "TIMESTAMP") + private LocalDateTime authExpiredAt; + + @Size(max = 255) + @Column(name = "reset_token") + private String resetToken; + + @Convert(converter = LocalDateTimeAttributeConverter.class) + @Column(name = "reset_expired_at", columnDefinition = "TIMESTAMP") + private LocalDateTime resetExpiredAt; + + @Column(name = "device_token", nullable = true) + private String deviceToken; + + @Builder + private User( + String password, + String nickname, + String name, + String phoneNumber, + UserType userType, + String email, + UserGender gender, + boolean isAuthed, + LocalDateTime lastLoggedAt, + String profileImageUrl, + Boolean isDeleted, + String authToken, + LocalDateTime authExpiredAt, + String resetToken, + LocalDateTime resetExpiredAt, + String deviceToken + ) { + this.password = password; + this.nickname = nickname; + this.name = name; + this.phoneNumber = phoneNumber; + this.userType = userType; + this.email = email; + this.gender = gender; + this.isAuthed = isAuthed; + this.lastLoggedAt = lastLoggedAt; + this.profileImageUrl = profileImageUrl; + this.isDeleted = isDeleted; + this.authToken = authToken; + this.authExpiredAt = authExpiredAt; + this.resetToken = resetToken; + this.resetExpiredAt = resetExpiredAt; + this.deviceToken = deviceToken; + } + + public boolean isSamePassword(PasswordEncoder passwordEncoder, String password) { + return passwordEncoder.matches(password, this.password); + } + + public void permitNotification(String deviceToken) { + this.deviceToken = deviceToken; + } + + public void rejectNotification() { + this.deviceToken = null; + } + + public void updateLastLoggedTime(LocalDateTime lastLoggedTime) { + lastLoggedAt = lastLoggedTime; + } + + public void updatePassword(PasswordEncoder passwordEncoder, String password) { + this.password = passwordEncoder.encode(password); + } + + public void generateResetTokenForFindPassword(Clock clock) { + this.resetExpiredAt = LocalDateTime.now(clock).plusHours(1); + this.resetToken = this.email + this.resetExpiredAt; + } + + public void update(String nickname, String name, String phoneNumber, UserGender gender) { + this.nickname = nickname; + this.name = name; + this.phoneNumber = phoneNumber; + this.gender = gender; + } + + public void updateStudentPassword(PasswordEncoder passwordEncoder, String password) { + if (password != null && !password.isEmpty()) + this.password = passwordEncoder.encode(password); + } + + public void auth() { + this.isAuthed = true; + } + + public void validateResetToken() { + if (resetExpiredAt.isBefore(LocalDateTime.now())) { + throw UserResetTokenExpiredException.withDetail("resetToken: " + resetToken); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/UserDeleteEvent.java b/src/main/java/in/koreatech/koin/domain/user/model/UserDeleteEvent.java new file mode 100644 index 000000000..d355f7fdc --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/UserDeleteEvent.java @@ -0,0 +1,8 @@ +package in.koreatech.koin.domain.user.model; + +public record UserDeleteEvent( + String email, + UserType userType +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/UserEventListener.java b/src/main/java/in/koreatech/koin/domain/user/model/UserEventListener.java new file mode 100644 index 000000000..0b5d8d7aa --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/UserEventListener.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.domain.user.model; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import in.koreatech.koin.global.domain.slack.SlackClient; +import in.koreatech.koin.global.domain.slack.model.SlackNotificationFactory; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional(propagation = Propagation.REQUIRES_NEW) +public class UserEventListener { + + private final SlackClient slackClient; + private final SlackNotificationFactory slackNotificationFactory; + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onUserDeleteEvent(UserDeleteEvent event) { + var notification = slackNotificationFactory.generateUserDeleteNotification(event.email(), event.userType()); + slackClient.sendMessage(notification); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/UserGender.java b/src/main/java/in/koreatech/koin/domain/user/model/UserGender.java new file mode 100644 index 000000000..65a4f8b91 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/UserGender.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.user.model; + +import java.util.Arrays; + +import in.koreatech.koin.domain.user.exception.UserGenderNotValidException; + +public enum UserGender { + MAN, + WOMAN, + ; + + public static UserGender from(Integer index) { + if (index == null) { + return null; + } + return Arrays.stream(values()) + .filter(it -> it.ordinal() == index) + .findAny() + .orElseThrow(() -> UserGenderNotValidException.withDetail("index : " + index)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/UserIdentity.java b/src/main/java/in/koreatech/koin/domain/user/model/UserIdentity.java new file mode 100644 index 000000000..791d24627 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/UserIdentity.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.user.model; + +import lombok.Getter; + +/** + * 신원 (0: 학생, 1: 대학원생, 2: 교수, 3: 교직원, 4: 졸업생, 5: 점주) + */ +@Getter +public enum UserIdentity { + UNDERGRADUATE("학부생"), + GRADUATE("대학원생"), + PROFESSOR("교수"), + STAFF("교직원"), + ALUMNI("졸업생"), + OWNER("점주"), + ; + + private final String value; + + UserIdentity(String value) { + this.value = value; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/UserToken.java b/src/main/java/in/koreatech/koin/domain/user/model/UserToken.java new file mode 100644 index 000000000..094c12fbf --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/UserToken.java @@ -0,0 +1,34 @@ +package in.koreatech.koin.domain.user.model; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import lombok.Getter; + +@Getter +@RedisHash("refreshToken") +public class UserToken { + + private static final long REFRESH_TOKEN_EXPIRE_DAY = 14L; + + @Id + private Integer id; + + private final String refreshToken; + + @TimeToLive(unit = TimeUnit.DAYS) + private final Long expiration; + + private UserToken(Integer id, String refreshToken, Long expiration) { + this.id = id; + this.refreshToken = refreshToken; + this.expiration = expiration; + } + + public static UserToken create(Integer userId, String refreshToken) { + return new UserToken(userId, refreshToken, REFRESH_TOKEN_EXPIRE_DAY); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/UserType.java b/src/main/java/in/koreatech/koin/domain/user/model/UserType.java new file mode 100644 index 000000000..7975133bf --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/model/UserType.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.user.model; + +import lombok.Getter; + +@Getter +public enum UserType { + STUDENT("STUDENT", "학생"), + OWNER("OWNER", "사장님"), + COOP("COOP", "영양사"), + ; + + public static final int ANONYMOUS_ID = 0; + + private final String value; + private final String description; + + UserType(String value, String description) { + this.value = value; + this.description = description; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/repository/StudentRepository.java b/src/main/java/in/koreatech/koin/domain/user/repository/StudentRepository.java new file mode 100644 index 000000000..6aa2ba504 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/repository/StudentRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.user.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.user.exception.UserNotFoundException; +import in.koreatech.koin.domain.user.model.Student; + +public interface StudentRepository extends Repository { + + Student save(Student student); + + Optional findById(Integer userId); + + default Student getById(Integer userId) { + return findById(userId) + .orElseThrow(() -> UserNotFoundException.withDetail("userId: " + userId)); + } + + void deleteByUserId(Integer userId); +} diff --git a/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java b/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java new file mode 100644 index 000000000..fd576c4c7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.domain.user.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.user.exception.UserNotFoundException; +import in.koreatech.koin.domain.user.model.User; + +public interface UserRepository extends Repository { + + User save(User user); + + Optional findByEmail(String email); + + Optional findByPhoneNumber(String phoneNumber); + + Optional findById(Integer id); + + Optional findByNickname(String nickname); + + Optional findByAuthToken(String authToken); + + Optional findAllByResetToken(String resetToken); + + default User getByEmail(String email) { + return findByEmail(email) + .orElseThrow(() -> UserNotFoundException.withDetail("email: " + email)); + } + + default User getById(Integer userId) { + return findById(userId) + .orElseThrow(() -> UserNotFoundException.withDetail("userId: " + userId)); + } + + default User getByNickname(String nickname) { + return findByNickname(nickname) + .orElseThrow(() -> UserNotFoundException.withDetail("nickname: " + nickname)); + } + + default User getByResetToken(String resetToken) { + return findAllByResetToken(resetToken) + .orElseThrow(() -> UserNotFoundException.withDetail("resetToken: " + resetToken)); + } + + boolean existsByNickname(String nickname); + + void delete(User user); + + List findAllByDeviceTokenIsNotNull(); +} diff --git a/src/main/java/in/koreatech/koin/domain/user/repository/UserTokenRepository.java b/src/main/java/in/koreatech/koin/domain/user/repository/UserTokenRepository.java new file mode 100644 index 000000000..844995110 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/repository/UserTokenRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.user.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public interface UserTokenRepository extends Repository { + + UserToken save(UserToken userToken); + + Optional findById(Integer userId); + + void deleteById(Integer id); + + default UserToken getById(Integer userId) { + return findById(userId) + .orElseThrow(() -> new KoinIllegalArgumentException("refresh token이 존재하지 않습니다.", "userId: " + userId)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java b/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java new file mode 100644 index 000000000..66c782ea9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java @@ -0,0 +1,159 @@ +package in.koreatech.koin.domain.user.service; + +import java.time.Clock; +import java.util.Optional; + +import org.joda.time.LocalDateTime; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.servlet.ModelAndView; + +import in.koreatech.koin.domain.user.dto.AuthTokenRequest; +import in.koreatech.koin.domain.user.dto.FindPasswordRequest; +import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; +import in.koreatech.koin.domain.user.dto.StudentResponse; +import in.koreatech.koin.domain.user.dto.StudentUpdateRequest; +import in.koreatech.koin.domain.user.dto.StudentUpdateResponse; +import in.koreatech.koin.domain.user.dto.UserPasswordChangeRequest; +import in.koreatech.koin.domain.user.exception.DuplicationNicknameException; +import in.koreatech.koin.domain.user.exception.StudentDepartmentNotValidException; +import in.koreatech.koin.domain.user.exception.StudentNumberNotValidException; +import in.koreatech.koin.domain.user.model.AuthResult; +import in.koreatech.koin.domain.user.model.Student; +import in.koreatech.koin.domain.user.model.StudentDepartment; +import in.koreatech.koin.domain.user.model.StudentEmailRequestEvent; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserGender; +import in.koreatech.koin.domain.user.repository.StudentRepository; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; +import in.koreatech.koin.global.domain.email.form.StudentPasswordChangeData; +import in.koreatech.koin.global.domain.email.form.StudentRegistrationData; +import in.koreatech.koin.global.domain.email.model.EmailAddress; +import in.koreatech.koin.global.domain.email.service.MailService; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudentService { + + private final StudentRepository studentRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final MailService mailService; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + public StudentResponse getStudent(Integer userId) { + Student student = studentRepository.getById(userId); + return StudentResponse.from(student); + } + + @Transactional + public StudentUpdateResponse updateStudent(Integer userId, StudentUpdateRequest request) { + Student student = studentRepository.getById(userId); + User user = student.getUser(); + checkNicknameDuplication(request.nickname(), userId); + checkDepartmentValid(request.major()); + user.update(request.nickname(), request.name(), + request.phoneNumber(), UserGender.from(request.gender())); + user.updateStudentPassword(passwordEncoder, request.password()); + student.update(request.studentNumber(), request.major()); + studentRepository.save(student); + + return StudentUpdateResponse.from(student); + } + + public void checkNicknameDuplication(String nickname, Integer userId) { + User checkUser = userRepository.getById(userId); + if (nickname != null && !nickname.equals(checkUser.getNickname()) + && userRepository.existsByNickname(nickname)) { + throw DuplicationNicknameException.withDetail("nickname : " + nickname); + } + } + + public void checkDepartmentValid(String department) { + if (department != null && !StudentDepartment.isValid(department)) { + throw StudentDepartmentNotValidException.withDetail("학부(학과) : " + department); + } + } + + @Transactional + public ModelAndView authenticate(AuthTokenRequest request) { + Optional user = userRepository.findByAuthToken(request.authToken()); + return new AuthResult(user, eventPublisher, clock).toModelAndViewForStudent(); + } + + @Transactional + public void studentRegister(StudentRegisterRequest request, String serverURL) { + Student student = request.toStudent(passwordEncoder, clock); + + validateStudentRegister(student); + + studentRepository.save(student); + userRepository.save(student.getUser()); + + mailService.sendMail(request.email(), new StudentRegistrationData(serverURL, student.getUser().getAuthToken())); + eventPublisher.publishEvent(new StudentEmailRequestEvent(request.email())); + } + + private void validateStudentRegister(Student student) { + EmailAddress emailAddress = EmailAddress.from(student.getUser().getEmail()); + emailAddress.validateKoreatechEmail(); + + validateDataExist(student); + validateStudentNumber(student.getStudentNumber()); + checkDepartmentValid(student.getDepartment()); + } + + private void validateDataExist(Student student) { + userRepository.findByEmail(student.getUser().getEmail()) + .ifPresent(user -> { + throw DuplicationEmailException.withDetail("email: " + student.getUser().getEmail()); + }); + + if (student.getUser().getNickname() != null) { + userRepository.findByNickname(student.getUser().getNickname()) + .ifPresent(user -> { + throw DuplicationNicknameException.withDetail("nickname: " + student.getUser().getNickname()); + }); + } + } + + private void validateStudentNumber(String studentNumber) { + if (studentNumber == null) { + return; + } + int studentNumberYear = Student.parseStudentNumberYear(studentNumber); + if (studentNumberYear < 1992 + || LocalDateTime.now().getYear() < studentNumberYear) { + throw StudentNumberNotValidException.withDetail("studentNumber: " + studentNumber); + } + } + + @Transactional + public void findPassword(FindPasswordRequest request, String serverURL) { + User user = userRepository.getByEmail(request.email()); + user.generateResetTokenForFindPassword(clock); + User authedUser = userRepository.save(user); + mailService.sendMail(request.email(), new StudentPasswordChangeData(serverURL, authedUser.getResetToken())); + } + + public ModelAndView checkResetToken(String resetToken, String serverUrl) { + ModelAndView modelAndView = new ModelAndView("change_password_config"); + modelAndView.addObject("contextPath", serverUrl); + modelAndView.addObject("resetToken", resetToken); + return modelAndView; + } + + @Transactional + public void changePassword(UserPasswordChangeRequest request, String resetToken) { + User authedUser = userRepository.getByResetToken(resetToken); + authedUser.validateResetToken(); + authedUser.updatePassword(passwordEncoder, request.password()); + userRepository.save(authedUser); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java new file mode 100644 index 000000000..a7f46063a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java @@ -0,0 +1,140 @@ +package in.koreatech.koin.domain.user.service; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.owner.repository.OwnerAttachmentRepository; +import in.koreatech.koin.domain.owner.repository.OwnerRepository; +import in.koreatech.koin.domain.shop.repository.ShopRepository; +import in.koreatech.koin.domain.user.dto.AuthResponse; +import in.koreatech.koin.domain.user.dto.CoopResponse; +import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; +import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest; +import in.koreatech.koin.domain.user.dto.UserLoginRequest; +import in.koreatech.koin.domain.user.dto.UserLoginResponse; +import in.koreatech.koin.domain.user.dto.UserPasswordCheckRequest; +import in.koreatech.koin.domain.user.dto.UserTokenRefreshRequest; +import in.koreatech.koin.domain.user.dto.UserTokenRefreshResponse; +import in.koreatech.koin.domain.user.exception.DuplicationNicknameException; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserDeleteEvent; +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.StudentRepository; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.repository.UserTokenRepository; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import in.koreatech.koin.global.auth.exception.AuthorizationException; +import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final StudentRepository studentRepository; + private final OwnerRepository ownerRepository; + private final ShopRepository shopRepository; + private final OwnerAttachmentRepository ownerAttachmentRepository; + private final PasswordEncoder passwordEncoder; + private final UserTokenRepository userTokenRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public UserLoginResponse login(UserLoginRequest request) { + User user = userRepository.getByEmail(request.email()); + + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + + if (!user.isAuthed()) { + throw new AuthorizationException("미인증 상태입니다. 아우누리에서 인증메일을 확인해주세요"); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedToken = userTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(LocalDateTime.now()); + User saved = userRepository.save(user); + + return UserLoginResponse.of(accessToken, savedToken.getRefreshToken(), saved.getUserType().getValue()); + } + + @Transactional + public void logout(Integer userId) { + userTokenRepository.deleteById(userId); + } + + public UserTokenRefreshResponse refresh(UserTokenRefreshRequest request) { + String userId = getUserId(request.refreshToken()); + UserToken userToken = userTokenRepository.getById(Integer.parseInt(userId)); + if (!Objects.equals(userToken.getRefreshToken(), request.refreshToken())) { + throw new KoinIllegalArgumentException("refresh token이 일치하지 않습니다.", "request: " + request); + } + User user = userRepository.getById(userToken.getId()); + + String accessToken = jwtProvider.createToken(user); + return UserTokenRefreshResponse.of(accessToken, userToken.getRefreshToken()); + } + + private String getUserId(String refreshToken) { + String[] split = refreshToken.split("-"); + if (split.length == 0) { + throw new AuthorizationException("올바르지 않은 인증 토큰입니다. refreshToken: " + refreshToken); + } + return split[split.length - 1]; + } + + @Transactional + public void withdraw(Integer userId) { + User user = userRepository.getById(userId); + if (user.getUserType() == UserType.STUDENT) { + studentRepository.deleteByUserId(userId); + } else if (user.getUserType() == UserType.OWNER) { + ownerRepository.deleteByUserId(userId); + } + userRepository.delete(user); + eventPublisher.publishEvent(new UserDeleteEvent(user.getEmail(), user.getUserType())); + } + + public void checkPassword(UserPasswordCheckRequest request, Integer userId) { + User user = userRepository.getById(userId); + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new AuthenticationException("올바르지 않은 비밀번호입니다."); + } + } + + public void checkExistsEmail(EmailCheckExistsRequest request) { + userRepository.findByEmail(request.email()).ifPresent(user -> { + throw DuplicationEmailException.withDetail("email: " + user.getEmail()); + }); + } + + public void checkUserNickname(NicknameCheckExistsRequest request) { + userRepository.findByNickname(request.nickname()).ifPresent(user -> { + throw DuplicationNicknameException.withDetail("nickname: " + request.nickname()); + }); + } + + public AuthResponse getAuth(Integer userId) { + User user = userRepository.getById(userId); + return AuthResponse.from(user); + } + + public CoopResponse getCoop(Integer userId) { + User user = userRepository.getById(userId); + return CoopResponse.from(user); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/version/controller/VersionController.java b/src/main/java/in/koreatech/koin/domain/version/controller/VersionController.java new file mode 100644 index 000000000..c7ab796f4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/version/controller/VersionController.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.version.controller; + +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.RestController; + +import in.koreatech.koin.domain.version.dto.VersionResponse; +import in.koreatech.koin.domain.version.service.VersionService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class VersionController { + + private final VersionService versionService; + + @GetMapping("/versions/{type}") + public ResponseEntity getVersions(@PathVariable(value = "type") String type) { + VersionResponse response = versionService.getVersion(type); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/version/dto/VersionResponse.java b/src/main/java/in/koreatech/koin/domain/version/dto/VersionResponse.java new file mode 100644 index 000000000..e546bb9ef --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/version/dto/VersionResponse.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.domain.version.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.version.model.Version; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record VersionResponse( + @Schema(description = "버전 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "버전", example = "1.0.0", requiredMode = REQUIRED) + String version, + + @Schema(description = "버전 타입", example = "android", requiredMode = REQUIRED) + String type, + + @Schema(description = "생성일", example = "2021-06-21 13:00:00", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @Schema(description = "수정일", example = "2021-06-21", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDateTime updatedAt +) { + + public static VersionResponse from(Version version) { + return new VersionResponse( + version.getId(), + version.getVersion(), + version.getType(), + version.getCreatedAt(), + version.getUpdatedAt() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/version/exception/VersionTypeNotFoundException.java b/src/main/java/in/koreatech/koin/domain/version/exception/VersionTypeNotFoundException.java new file mode 100644 index 000000000..a57105d1a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/version/exception/VersionTypeNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.version.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class VersionTypeNotFoundException extends DataNotFoundException { + + public static final String DEFAULT_MESSAGE = "존재하지 않는 버전 타입입니다."; + + public VersionTypeNotFoundException(String message) { + super(message); + } + + public VersionTypeNotFoundException(String message, String detail) { + super(message, detail); + } + + public static VersionTypeNotFoundException withDetail(String detail) { + return new VersionTypeNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/version/model/Version.java b/src/main/java/in/koreatech/koin/domain/version/model/Version.java new file mode 100644 index 000000000..9de6af4c9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/version/model/Version.java @@ -0,0 +1,60 @@ +package in.koreatech.koin.domain.version.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.Clock; +import java.time.LocalDate; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "versions") +@NoArgsConstructor(access = PROTECTED) +public class Version extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Size(max = 50) + @NotNull + @Column(name = "version", nullable = false, length = 50) + private String version; + + @NotNull + @Column(name = "type", length = 50, unique = true) + private String type; + + @Builder + private Version(@NotNull String version, @NotNull String type) { + this.version = version; + this.type = type; + } + + public void update(Clock clock) { + version = generateVersionName(clock); + } + + public void updateAndroid(String version) { + this.version = version; + } + + private String generateVersionName(Clock clock) { + String year = Integer.toString(LocalDate.now().getYear()); + String padding = "0_"; + String epochSeconds = Long.toString(clock.instant().getEpochSecond()); + return year + padding + epochSeconds; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/version/model/VersionType.java b/src/main/java/in/koreatech/koin/domain/version/model/VersionType.java new file mode 100644 index 000000000..630dfad4e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/version/model/VersionType.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.domain.version.model; + +import java.util.Arrays; + +import in.koreatech.koin.domain.version.exception.VersionTypeNotFoundException; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum VersionType { + ANDROID("android"), + TIMETABLE("timetable"), + SHUTTLE("shuttle_bus_timetable"), + CITY("city_bus_timetable"), + EXPRESS("express_bus_timetable"), + ; + + private final String value; + + public static VersionType from(String value) { + return Arrays.stream(values()) + .filter(versionType -> versionType.value.equals(value)) + .findAny() + .orElseThrow(() -> VersionTypeNotFoundException.withDetail("versionType: " + value)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/version/repository/VersionRepository.java b/src/main/java/in/koreatech/koin/domain/version/repository/VersionRepository.java new file mode 100644 index 000000000..ceb8c84eb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/version/repository/VersionRepository.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.version.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.version.exception.VersionTypeNotFoundException; +import in.koreatech.koin.domain.version.model.Version; +import in.koreatech.koin.domain.version.model.VersionType; + +public interface VersionRepository extends Repository { + + Version save(Version version); + + Optional findByType(String type); + + default Version getByType(VersionType type) { + return this.findByType(type.getValue()) + .orElseThrow(() -> VersionTypeNotFoundException.withDetail("versionType: " + type)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/version/service/VersionService.java b/src/main/java/in/koreatech/koin/domain/version/service/VersionService.java new file mode 100644 index 000000000..cc3deaefe --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/version/service/VersionService.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.version.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.version.dto.VersionResponse; +import in.koreatech.koin.domain.version.model.Version; +import in.koreatech.koin.domain.version.model.VersionType; +import in.koreatech.koin.domain.version.repository.VersionRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VersionService { + + private final VersionRepository versionRepository; + + public VersionResponse getVersion(String type) { + Version version = versionRepository.getByType(VersionType.from(type)); + return VersionResponse.from(version); + } +} diff --git a/src/main/java/in/koreatech/koin/dto/TrackResponse.java b/src/main/java/in/koreatech/koin/dto/TrackResponse.java deleted file mode 100644 index 97c9db700..000000000 --- a/src/main/java/in/koreatech/koin/dto/TrackResponse.java +++ /dev/null @@ -1,36 +0,0 @@ -package in.koreatech.koin.dto; - -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonNaming; -import in.koreatech.koin.domain.Track; -import java.time.LocalDateTime; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor(access = lombok.AccessLevel.PRIVATE) -@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) -public class TrackResponse { - - private Long id; - private String name; - private Integer headcount; - private Boolean isDeleted; - - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime createdAt; - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime updatedAt; - - public static TrackResponse from(Track track) { - return new TrackResponse( - track.getId(), - track.getName(), - track.getHeadcount(), - track.getIsDeleted(), - track.getCreatedAt(), - track.getUpdatedAt() - ); - } -} diff --git a/src/main/java/in/koreatech/koin/dto/TrackSingleResponse.java b/src/main/java/in/koreatech/koin/dto/TrackSingleResponse.java deleted file mode 100644 index 61cfa9abf..000000000 --- a/src/main/java/in/koreatech/koin/dto/TrackSingleResponse.java +++ /dev/null @@ -1,104 +0,0 @@ -package in.koreatech.koin.dto; - -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonNaming; -import in.koreatech.koin.domain.Member; -import in.koreatech.koin.domain.TechStack; -import in.koreatech.koin.domain.Track; -import java.time.LocalDateTime; -import java.util.List; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class TrackSingleResponse { - - @JsonProperty("TrackName") - private String trackName; - - @JsonProperty("TechStacks") - private List innerTechStackResponses; - - @JsonProperty("Members") - private List innerMemberResponses; - - public static TrackSingleResponse of(Track track, List members, List techStacks) { - return new TrackSingleResponse( - track.getName(), - techStacks.stream() - .map(InnerTechStackResponse::from) - .toList(), - members.stream() - .map(member -> InnerMemberResponse.from(member, track.getName())) - .toList() - ); - } - - @Getter - @AllArgsConstructor(access = AccessLevel.PUBLIC) - @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) - public static class InnerTechStackResponse { - - private Long id; - private String name; - private String description; - private String imageUrl; - private Long trackId; - private Boolean isDeleted; - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime createdAt; - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime updatedAt; - - public static InnerTechStackResponse from(TechStack techStack) { - return new InnerTechStackResponse( - techStack.getId(), - techStack.getName(), - techStack.getDescription(), - techStack.getImageUrl(), - techStack.getTrackId(), - techStack.getIsDeleted(), - techStack.getCreatedAt(), - techStack.getUpdatedAt() - ); - } - } - - @Getter - @AllArgsConstructor(access = AccessLevel.PUBLIC) - @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) - public static class InnerMemberResponse { - - private Integer id; - private String name; - private String studentNumber; - private String position; - private String track; - private String email; - private String imageUrl; - private Boolean isDeleted; - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime createdAt; - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime updatedAt; - - public static InnerMemberResponse from(Member member, String trackName) { - return new InnerMemberResponse( - member.getId(), - member.getName(), - member.getStudentNumber(), - member.getPosition(), - trackName, - member.getEmail(), - member.getImageUrl(), - member.getIsDeleted(), - member.getCreatedAt(), - member.getUpdatedAt() - ); - } - } -} diff --git a/src/main/java/in/koreatech/koin/global/auth/Auth.java b/src/main/java/in/koreatech/koin/global/auth/Auth.java new file mode 100644 index 000000000..3eb086daf --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/Auth.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.global.auth; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import in.koreatech.koin.domain.user.model.UserType; +import io.swagger.v3.oas.annotations.Hidden; + +@Hidden // Swagger 문서에 표시하지 않음 +@Target(PARAMETER) +@Retention(RUNTIME) +public @interface Auth { + + UserType[] permit() default {}; + + /** + * 임시토큰을 허용하는 옵션이다. + */ + boolean anonymous() default false; +} diff --git a/src/main/java/in/koreatech/koin/global/auth/AuthArgumentResolver.java b/src/main/java/in/koreatech/koin/global/auth/AuthArgumentResolver.java new file mode 100644 index 000000000..c66981ebd --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/AuthArgumentResolver.java @@ -0,0 +1,64 @@ +package in.koreatech.koin.global.auth; + +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; +import static java.util.Objects.requireNonNull; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +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; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.auth.exception.AuthorizationException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + private final UserRepository userRepository; + private final AuthContext authContext; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Auth.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + + Auth authAt = parameter.getParameterAnnotation(Auth.class); + requireNonNull(authAt); + List permitStatus = Arrays.asList(authAt.permit()); + if (authContext.isAnonymous() && authAt.anonymous()) { + return null; + } + Integer userId = authContext.getUserId(); + User user = userRepository.getById(userId); + + if (permitStatus.contains(user.getUserType())) { + if (!user.isAuthed()) { + if (user.getUserType() == OWNER) { + throw new AuthorizationException("관리자 인증 대기중입니다."); + } + if (user.getUserType() == STUDENT) { + throw new AuthorizationException("미인증 상태입니다. 아우누리에서 인증메일을 확인해주세요"); + } + throw AuthorizationException.withDetail("userId: " + user.getId()); + } + return user.getId(); + } + HttpServletRequest request = (HttpServletRequest)webRequest.getNativeRequest(); + throw AuthorizationException.withDetail("header: " + request); + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/AuthContext.java b/src/main/java/in/koreatech/koin/global/auth/AuthContext.java new file mode 100644 index 000000000..aefac62f3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/AuthContext.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.global.auth; + +import java.util.Objects; + +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.global.auth.exception.AuthenticationException; + +@Component +@RequestScope +public class AuthContext { + + private Integer userId; + + public Integer getUserId() { + if (userId == null) { + throw AuthenticationException.withDetail("userId is null"); + } + return userId; + } + + public boolean isAnonymous() { + return Objects.equals(userId, UserType.ANONYMOUS_ID); + } + + public void setUserId(Integer userId) { + this.userId = userId; + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/ExtractAuthenticationInterceptor.java b/src/main/java/in/koreatech/koin/global/auth/ExtractAuthenticationInterceptor.java new file mode 100644 index 000000000..24ef6e33d --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/ExtractAuthenticationInterceptor.java @@ -0,0 +1,44 @@ +package in.koreatech.koin.global.auth; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +import java.util.Optional; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ExtractAuthenticationInterceptor implements HandlerInterceptor { + + private static final String BEARER_TYPE = "Bearer "; + private static final int BEARER_TYPE_LEN = 7; + + private final JwtProvider jwtProvider; + private final AuthContext authContext; + private final UserIdContext userIdContext; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + Optional.ofNullable(extractAccessToken(request)) + .map(jwtProvider::getUserId) + .ifPresent(userId -> { + authContext.setUserId(userId); + userIdContext.setUserId(userId); + }); + return true; + } + + public static String extractAccessToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) { + return bearerToken.substring(BEARER_TYPE_LEN); + } + return null; + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/JwtProvider.java b/src/main/java/in/koreatech/koin/global/auth/JwtProvider.java new file mode 100644 index 000000000..ab305670b --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/JwtProvider.java @@ -0,0 +1,86 @@ +package in.koreatech.koin.global.auth; + +import java.security.Key; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.user.exception.UserNotFoundException; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +@Component +public class JwtProvider { + + private final String secretKey; + private final Long expirationTime; + + public JwtProvider( + @Value("${jwt.secret-key}") String secretKey, + @Value("${jwt.access-token.expiration-time}") Long expirationTime + ) { + this.secretKey = secretKey; + this.expirationTime = expirationTime; + } + + public String createToken(User user) { + if (user == null) { + throw UserNotFoundException.withDetail("user: " + null); + } + Key key = getSecretKey(); + return Jwts.builder() + .signWith(key) + .header() + .add("typ", "JWT") + .add("alg", key.getAlgorithm()) + .and() + .claim("id", user.getId()) + .expiration(Date.from(Instant.now().plusMillis(expirationTime))) + .compact(); + } + + /** + * 임시 회원가입 토큰 생성 + */ + public String createTemporaryToken() { + Key key = getSecretKey(); + return Jwts.builder() + .signWith(key) + .header() + .add("typ", "JWT") + .add("alg", key.getAlgorithm()) + .and() + .claim("id", UserType.ANONYMOUS_ID) + .expiration(Date.from(Instant.now().plusMillis(expirationTime))) + .compact(); + } + + public Integer getUserId(String token) { + try { + String userId = Jwts.parser() + .verifyWith(getSecretKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .get("id") + .toString(); + return Integer.parseInt(userId); + } catch (JwtException e) { + throw AuthenticationException.withDetail("token: " + token); + } + } + + private SecretKey getSecretKey() { + String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes()); + return Keys.hmacShaKeyFor(encoded.getBytes()); + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/UserId.java b/src/main/java/in/koreatech/koin/global/auth/UserId.java new file mode 100644 index 000000000..8af4dc7fd --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/UserId.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.global.auth; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.Hidden; + +/** + * 토큰으로부터 사용자 ID를 추출하여 가져온다. + *

+ * Nullable: 사용자 ID가 없을 수도 있다. + */ +@Hidden // Swagger 문서에 표시하지 않음 +@Target(PARAMETER) +@Retention(RUNTIME) +public @interface UserId { + +} diff --git a/src/main/java/in/koreatech/koin/global/auth/UserIdArgumentResolver.java b/src/main/java/in/koreatech/koin/global/auth/UserIdArgumentResolver.java new file mode 100644 index 000000000..0052b636d --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/UserIdArgumentResolver.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.global.auth; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +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; + +import lombok.RequiredArgsConstructor; + +/** + * 토큰에 있는 사용자 ID를 가져온다 (인증 X) + */ +@Component +@RequiredArgsConstructor +public class UserIdArgumentResolver implements HandlerMethodArgumentResolver { + + private final UserIdContext userIdContext; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserId.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + + return userIdContext.getUserId(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/UserIdContext.java b/src/main/java/in/koreatech/koin/global/auth/UserIdContext.java new file mode 100644 index 000000000..e593dc30a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/UserIdContext.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.global.auth; + +import java.util.Objects; + +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +import in.koreatech.koin.domain.user.model.UserType; + +@Component +@RequestScope +public class UserIdContext { + + private Integer userId; + + public Integer getUserId() { + if (Objects.equals(userId, UserType.ANONYMOUS_ID)) { + return null; + } + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/exception/AuthenticationException.java b/src/main/java/in/koreatech/koin/global/auth/exception/AuthenticationException.java new file mode 100644 index 000000000..02cba3cab --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/exception/AuthenticationException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.auth.exception; + +import in.koreatech.koin.global.exception.KoinException; + +public class AuthenticationException extends KoinException { + + private static final String DEFAULT_MESSAGE = "올바르지 않은 인증정보입니다."; + + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException(String message, String detail) { + super(message, detail); + } + + public static AuthenticationException withDetail(String detail) { + return new AuthenticationException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/auth/exception/AuthorizationException.java b/src/main/java/in/koreatech/koin/global/auth/exception/AuthorizationException.java new file mode 100644 index 000000000..2b24174ef --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/auth/exception/AuthorizationException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.auth.exception; + +import in.koreatech.koin.global.exception.KoinException; + +public class AuthorizationException extends KoinException { + + private static final String DEFAULT_MESSAGE = "권한이 없습니다."; + + public AuthorizationException(String message) { + super(message); + } + + public AuthorizationException(String message, String detail) { + super(message, detail); + } + + public static AuthorizationException withDetail(String detail) { + return new AuthorizationException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/AsyncConfig.java b/src/main/java/in/koreatech/koin/global/config/AsyncConfig.java new file mode 100644 index 000000000..91ac007fe --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/AsyncConfig.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +@Configuration +public class AsyncConfig { + +} diff --git a/src/main/java/in/koreatech/koin/global/config/AwsSesConfig.java b/src/main/java/in/koreatech/koin/global/config/AwsSesConfig.java new file mode 100644 index 000000000..9e503e77d --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/AwsSesConfig.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceAsync; +import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceAsyncClient; + +@Configuration +public class AwsSesConfig { + + private final String accessKey; + private final String secretKey; + + public AwsSesConfig( + @Value("${aws.ses.access-key}") String accessKey, + @Value("${aws.ses.secret-key}") String secretKey + ) { + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + @Bean + public AmazonSimpleEmailServiceAsync amazonSimpleEmailServiceAsync() { + BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonSimpleEmailServiceAsyncClient.asyncBuilder() + .withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials)) + .withRegion(Regions.US_WEST_2) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/ClockConfig.java b/src/main/java/in/koreatech/koin/global/config/ClockConfig.java new file mode 100644 index 000000000..adb22d795 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/ClockConfig.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.global.config; + +import java.time.Clock; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("!test") +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/CorsProperties.java b/src/main/java/in/koreatech/koin/global/config/CorsProperties.java new file mode 100644 index 000000000..903205a08 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/CorsProperties.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.global.config; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "cors") +public record CorsProperties( + List allowedOrigins +) { + +} diff --git a/src/main/java/in/koreatech/koin/config/JpaConfiguration.java b/src/main/java/in/koreatech/koin/global/config/JpaConfiguration.java similarity index 64% rename from src/main/java/in/koreatech/koin/config/JpaConfiguration.java rename to src/main/java/in/koreatech/koin/global/config/JpaConfiguration.java index f1a948c05..46ebc0cba 100644 --- a/src/main/java/in/koreatech/koin/config/JpaConfiguration.java +++ b/src/main/java/in/koreatech/koin/global/config/JpaConfiguration.java @@ -1,10 +1,12 @@ -package in.koreatech.koin.config; +package in.koreatech.koin.global.config; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @Configuration @EnableJpaAuditing +@Profile("!test") public class JpaConfiguration { } diff --git a/src/main/java/in/koreatech/koin/global/config/LocalDateTimeAttributeConverter.java b/src/main/java/in/koreatech/koin/global/config/LocalDateTimeAttributeConverter.java new file mode 100644 index 000000000..c2320a9e2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/LocalDateTimeAttributeConverter.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.global.config; + +import static java.time.temporal.ChronoField.MILLI_OF_SECOND; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class LocalDateTimeAttributeConverter implements AttributeConverter { + + private static final DateTimeFormatter formatter = new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd HH:mm:ss") + .optionalStart() + .appendFraction(MILLI_OF_SECOND, 0, 3, true) + .optionalEnd() + .toFormatter(); + + @Override + public String convertToDatabaseColumn(LocalDateTime localDateTime) { + if (localDateTime == null) { + return null; + } + return localDateTime.format(formatter); + } + + @Override + public LocalDateTime convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + return LocalDateTime.parse(dbData, formatter); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/LocalTimeAttributeConverter.java b/src/main/java/in/koreatech/koin/global/config/LocalTimeAttributeConverter.java new file mode 100644 index 000000000..03a810194 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/LocalTimeAttributeConverter.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.global.config; + +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class LocalTimeAttributeConverter implements AttributeConverter { + + private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); + + @Override + public String convertToDatabaseColumn(LocalTime localTime) { + if (localTime == null) + return null; + return localTime.format(formatter); + } + + @Override + public LocalTime convertToEntityAttribute(String dbData) { + if (dbData == null) + return null; + return LocalTime.parse(dbData, formatter); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/PasswordConfig.java b/src/main/java/in/koreatech/koin/global/config/PasswordConfig.java new file mode 100644 index 000000000..e9321a775 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/PasswordConfig.java @@ -0,0 +1,15 @@ +package in.koreatech.koin.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/RestTemplateConfig.java b/src/main/java/in/koreatech/koin/global/config/RestTemplateConfig.java new file mode 100644 index 000000000..469f8e008 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/RestTemplateConfig.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.global.config; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.time.Duration; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder. + requestFactory(() -> new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory())). + setConnectTimeout(Duration.ofMillis(5000)) + .setReadTimeout(Duration.ofMillis(5000)) + .additionalMessageConverters(new StringHttpMessageConverter(UTF_8)).build(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/S3Config.java b/src/main/java/in/koreatech/koin/global/config/S3Config.java new file mode 100644 index 000000000..25b1ecb50 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/S3Config.java @@ -0,0 +1,55 @@ +package in.koreatech.koin.global.config; + +import static software.amazon.awssdk.regions.Region.AP_NORTHEAST_2; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.ClientConfiguration; +import com.amazonaws.Protocol; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3Config { + + private final String accessKey; + private final String secretKey; + + public S3Config( + @Value("${s3.key}") String accessKey, + @Value("${s3.secret}") String secretKey + ) { + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + /** + * S3Presigner 사용 후 close()를 권장하므로, Builder 를 반환하여 필요 시 객체를 만들어 사용 후 close 되도록 구현. + */ + @Bean + public S3Presigner.Builder s3PresignerBuilder() { + return S3Presigner.builder() + .credentialsProvider(InstanceProfileCredentialsProvider.create()) + .region(AP_NORTHEAST_2); + } + + @Bean + public AmazonS3 amazonS3ClientBuilder() { + ClientConfiguration clientConfig = new ClientConfiguration(); + clientConfig.setProtocol(Protocol.HTTPS); + + return AmazonS3ClientBuilder.standard() + .withRegion(Regions.AP_NORTHEAST_2) + .withClientConfiguration(clientConfig) + .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey))) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java new file mode 100644 index 000000000..5e8a6c6bf --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java @@ -0,0 +1,53 @@ +package in.koreatech.koin.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +@Configuration +public class SwaggerConfig { + + private final String serverUrl; + + public SwaggerConfig( + @Value("${swagger.server-url}") String serverUrl + ) { + this.serverUrl = serverUrl; + } + + @Bean + public OpenAPI openAPI() { + String jwt = "Jwt Authentication"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme() + .name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .description("토큰값을 입력하여 인증을 활성화할 수 있습니다.") + .bearerFormat("JWT") + ); + Server server = new Server(); + server.setUrl(serverUrl); + return new OpenAPI() + .openapi("3.1.0") + .components(new Components()) + .info(apiInfo()) + .addSecurityItem(securityRequirement) + .components(components) + .addServersItem(server); + } + + private Info apiInfo() { + return new Info() + .title("KOIN API") + .description("KOIN API 문서입니다.") + .version("0.0.1"); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/WebConfig.java b/src/main/java/in/koreatech/koin/global/config/WebConfig.java new file mode 100644 index 000000000..676bae0c1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/WebConfig.java @@ -0,0 +1,76 @@ +package in.koreatech.koin.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import in.koreatech.koin.domain.bus.controller.BusStationEnumConverter; +import in.koreatech.koin.domain.bus.controller.BusTypeEnumConverter; +import in.koreatech.koin.global.auth.AuthArgumentResolver; +import in.koreatech.koin.global.auth.ExtractAuthenticationInterceptor; +import in.koreatech.koin.global.auth.UserIdArgumentResolver; +import in.koreatech.koin.global.domain.notification.controller.NotificationSubscribeTypeConverter; +import in.koreatech.koin.global.domain.upload.controller.ImageUploadDomainEnumConverter; +import in.koreatech.koin.global.host.ServerURLArgumentResolver; +import in.koreatech.koin.global.host.ServerURLInterceptor; +import in.koreatech.koin.global.ipaddress.IpAddressArgumentResolver; +import in.koreatech.koin.global.ipaddress.IpAddressInterceptor; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final ExtractAuthenticationInterceptor extractAuthenticationInterceptor; + private final IpAddressArgumentResolver ipAddressArgumentResolver; + private final UserIdArgumentResolver userIdArgumentResolver; + private final AuthArgumentResolver authArgumentResolver; + private final IpAddressInterceptor ipAddressInterceptor; + private final ServerURLArgumentResolver serverURLArgumentResolver; + private final ServerURLInterceptor serverURLInterceptor; + private final CorsProperties corsProperties; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(extractAuthenticationInterceptor) + .addPathPatterns("/**") + .order(0); + registry.addInterceptor(ipAddressInterceptor) + .addPathPatterns("/**") + .order(1); + registry.addInterceptor(serverURLInterceptor) + .addPathPatterns("/**") + .order(2); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authArgumentResolver); + resolvers.add(ipAddressArgumentResolver); + resolvers.add(userIdArgumentResolver); + resolvers.add(serverURLArgumentResolver); + } + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(new BusTypeEnumConverter()); + registry.addConverter(new BusStationEnumConverter()); + registry.addConverter(new ImageUploadDomainEnumConverter()); + registry.addConverter(new NotificationSubscribeTypeConverter()); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(corsProperties.allowedOrigins().toArray(new String[0])) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/BaseEntity.java b/src/main/java/in/koreatech/koin/global/domain/BaseEntity.java similarity index 73% rename from src/main/java/in/koreatech/koin/domain/BaseEntity.java rename to src/main/java/in/koreatech/koin/global/domain/BaseEntity.java index 37c5f2943..323843175 100644 --- a/src/main/java/in/koreatech/koin/domain/BaseEntity.java +++ b/src/main/java/in/koreatech/koin/global/domain/BaseEntity.java @@ -1,14 +1,16 @@ -package in.koreatech.koin.domain; +package in.koreatech.koin.global.domain; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; import lombok.Getter; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Getter @MappedSuperclass @@ -17,11 +19,11 @@ public abstract class BaseEntity { @NotNull @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) + @Column(name = "created_at", columnDefinition = "TIMESTAMP", nullable = false, updatable = false) private LocalDateTime createdAt; @NotNull @LastModifiedDate - @Column(name = "updated_at", nullable = false, updatable = false) + @Column(name = "updated_at", columnDefinition = "TIMESTAMP", nullable = false, updatable = true) private LocalDateTime updatedAt; } diff --git a/src/main/java/in/koreatech/koin/global/domain/email/exception/DuplicationEmailException.java b/src/main/java/in/koreatech/koin/global/domain/email/exception/DuplicationEmailException.java new file mode 100644 index 000000000..3e6eb8ad2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/exception/DuplicationEmailException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.email.exception; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class DuplicationEmailException extends DuplicationException { + + private static final String DEFAULT_MESSAGE = "존재하는 이메일입니다."; + + public DuplicationEmailException(String message) { + super(message); + } + + public DuplicationEmailException(String message, String detail) { + super(message, detail); + } + + public static DuplicationEmailException withDetail(String detail) { + return new DuplicationEmailException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/exception/EmailAddressInvalidException.java b/src/main/java/in/koreatech/koin/global/domain/email/exception/EmailAddressInvalidException.java new file mode 100644 index 000000000..74a2597df --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/exception/EmailAddressInvalidException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.email.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class EmailAddressInvalidException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "유효하지 않는 이메일 주소입니다."; + + public EmailAddressInvalidException(String message) { + super(message); + } + + public EmailAddressInvalidException(String message, String detail) { + super(message, detail); + } + + public static EmailAddressInvalidException withDetail(String detail) { + return new EmailAddressInvalidException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/exception/VerifyNotFoundException.java b/src/main/java/in/koreatech/koin/global/domain/email/exception/VerifyNotFoundException.java new file mode 100644 index 000000000..706bc064e --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/exception/VerifyNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.email.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class VerifyNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 인증정보입니다."; + + public VerifyNotFoundException(String message) { + super(message); + } + + public VerifyNotFoundException(String message, String detail) { + super(message, detail); + } + + public static VerifyNotFoundException withDetail(String detail) { + return new VerifyNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/form/MailFormData.java b/src/main/java/in/koreatech/koin/global/domain/email/form/MailFormData.java new file mode 100644 index 000000000..23a89e160 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/form/MailFormData.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.global.domain.email.form; + +import java.util.Map; + +public interface MailFormData { + + Map getContent(); + + String getSubject(); + + String getFilePath(); +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/form/OwnerPasswordChangeData.java b/src/main/java/in/koreatech/koin/global/domain/email/form/OwnerPasswordChangeData.java new file mode 100644 index 000000000..08fb4f3d1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/form/OwnerPasswordChangeData.java @@ -0,0 +1,44 @@ +package in.koreatech.koin.global.domain.email.form; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Map; + +public class OwnerPasswordChangeData implements MailFormData { + + private static final String SUBJECT = "코인 사장님 비밀번호 찾기 이메일 인증"; + private static final String PATH = "owner_change_password_certificate_number"; + + private final String email; + private final String certificationCode; + private final LocalDateTime now; + + public OwnerPasswordChangeData(String email, String certificationCode, Clock clock) { + this.email = email; + this.certificationCode = certificationCode; + this.now = LocalDateTime.now(clock); + } + + @Override + public Map getContent() { + return Map.of( + "emailAddress", email, + "certificationCode", certificationCode, + "year", String.valueOf(now.getYear()), + "month", String.valueOf(now.getMonthValue()), + "day", String.valueOf(now.getDayOfMonth()), + "hour", String.valueOf(now.getHour()), + "minute", String.valueOf(now.getMinute()) + ); + } + + @Override + public String getSubject() { + return SUBJECT; + } + + @Override + public String getFilePath() { + return PATH; + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/form/OwnerRegistrationData.java b/src/main/java/in/koreatech/koin/global/domain/email/form/OwnerRegistrationData.java new file mode 100644 index 000000000..65438e71d --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/form/OwnerRegistrationData.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.global.domain.email.form; + +import java.util.Map; + +public class OwnerRegistrationData implements MailFormData { + + private static final String SUBJECT = "코인 사장님 회원가입 이메일 인증"; + private static final String PATH = "owner_register_certificate_number"; + + private final String certificationCode; + + public OwnerRegistrationData(String certificationCode) { + this.certificationCode = certificationCode; + } + + @Override + public Map getContent() { + return Map.of("certificationCode", certificationCode); + } + + @Override + public String getSubject() { + return SUBJECT; + } + + @Override + public String getFilePath() { + return PATH; + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/form/StudentPasswordChangeData.java b/src/main/java/in/koreatech/koin/global/domain/email/form/StudentPasswordChangeData.java new file mode 100644 index 000000000..3abb90d04 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/form/StudentPasswordChangeData.java @@ -0,0 +1,34 @@ +package in.koreatech.koin.global.domain.email.form; + +import java.util.Map; + +public class StudentPasswordChangeData implements MailFormData { + private static final String SUBJECT = "코인 패스워드 초기화 인증"; + private static final String PATH = "student_change_password_certificate_button"; + + private final String contextPath; + private final String resetToken; + + public StudentPasswordChangeData(String contextPath, String resetToken) { + this.contextPath = contextPath; + this.resetToken = resetToken; + } + + @Override + public Map getContent() { + return Map.of( + "contextPath", contextPath, + "resetToken", resetToken + ); + } + + @Override + public String getSubject() { + return SUBJECT; + } + + @Override + public String getFilePath() { + return PATH; + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/form/StudentRegistrationData.java b/src/main/java/in/koreatech/koin/global/domain/email/form/StudentRegistrationData.java new file mode 100644 index 000000000..df28f4ccf --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/form/StudentRegistrationData.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.global.domain.email.form; + +import java.util.Map; + +public class StudentRegistrationData implements MailFormData { + + private static final String SUBJECT = "학교 이메일 주소 인증"; + private static final String PATH = "student_register_certificate_number"; + + private final String contextPath; + private final String authToken; + + public StudentRegistrationData(String contextPath, String authToken) { + this.contextPath = contextPath; + this.authToken = authToken; + } + + @Override + public Map getContent() { + return Map.of( + "contextPath", contextPath, + "authToken", authToken + ); + } + + @Override + public String getSubject() { + return SUBJECT; + } + + @Override + public String getFilePath() { + return PATH; + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/model/CertificationCode.java b/src/main/java/in/koreatech/koin/global/domain/email/model/CertificationCode.java new file mode 100644 index 000000000..2c7449dc7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/model/CertificationCode.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.global.domain.email.model; + +import lombok.Getter; + +@Getter +public class CertificationCode { + + private final String value; + + private CertificationCode(String value) { + this.value = value; + } + + public static CertificationCode from(String value) { + return new CertificationCode(value); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/model/EmailAddress.java b/src/main/java/in/koreatech/koin/global/domain/email/model/EmailAddress.java new file mode 100644 index 000000000..ad4aac29b --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/model/EmailAddress.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.global.domain.email.model; + +import in.koreatech.koin.global.domain.email.exception.EmailAddressInvalidException; +import jakarta.validation.constraints.Email; + +public record EmailAddress( + @Email(message = "이메일 형식을 지켜주세요.", regexp = EmailAddress.EMAIL_PATTERN) + String email +) { + + private static final String LOCAL_PARTS_PATTERN = "^(?=.{1,64}@)[A-Za-z0-9\\+_-]+(\\.[A-Za-z0-9\\+_-]+)*@"; + private static final String DOMAIN_PATTERN = "[^-][A-Za-z0-9\\+-]+(\\.[A-Za-z0-9\\+-]+)*(\\.[A-Za-z]{2,})$"; + private static final String EMAIL_PATTERN = LOCAL_PARTS_PATTERN + DOMAIN_PATTERN; + + private static final String DOMAIN_SEPARATOR = "@"; + private static final String KOREATECH_DOMAIN = "koreatech.ac.kr"; + + public static EmailAddress from(String email) { + return new EmailAddress(email); + } + + public void validateKoreatechEmail() { + if (!domainForm().equals(KOREATECH_DOMAIN)) { + throw EmailAddressInvalidException.withDetail("email: " + email); + } + } + + private String domainForm() { + return email.substring(getSeparateIndex() + DOMAIN_SEPARATOR.length()); + } + + private int getSeparateIndex() { + return email.lastIndexOf(DOMAIN_SEPARATOR); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/model/Mail.java b/src/main/java/in/koreatech/koin/global/domain/email/model/Mail.java new file mode 100644 index 000000000..9cc0e04f6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/model/Mail.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.email.model; + +import org.thymeleaf.context.Context; + +import lombok.Builder; + +@Builder +public class Mail { + + private static final String CERTIFICATION_CODE = "certificationCode"; + + private final String certificationCode; + + private final Context context = new Context(); + + public Context convertToMap() { + context.setVariable(CERTIFICATION_CODE, certificationCode); + return context; + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/model/SesMailSender.java b/src/main/java/in/koreatech/koin/global/domain/email/model/SesMailSender.java new file mode 100644 index 000000000..11494608a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/model/SesMailSender.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.global.domain.email.model; + +import org.springframework.stereotype.Component; + +import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceAsync; +import com.amazonaws.services.simpleemail.model.Body; +import com.amazonaws.services.simpleemail.model.Content; +import com.amazonaws.services.simpleemail.model.Destination; +import com.amazonaws.services.simpleemail.model.Message; +import com.amazonaws.services.simpleemail.model.SendEmailRequest; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class SesMailSender { + + private final AmazonSimpleEmailServiceAsync amazonSimpleEmailServiceAsync; + + public void sendMail(String from, String to, String subject, String htmlBody) { + SendEmailRequest request = new SendEmailRequest() + .withDestination(new Destination().withToAddresses(to)) + .withSource(from) + .withMessage(new Message() + .withBody(new Body().withHtml(new Content().withCharset("UTF-8").withData(htmlBody))) + .withSubject(new Content().withCharset("UTF-8").withData(subject))); + + amazonSimpleEmailServiceAsync.sendEmailAsync(request); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/email/service/MailService.java b/src/main/java/in/koreatech/koin/global/domain/email/service/MailService.java new file mode 100644 index 000000000..450a4975b --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/email/service/MailService.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.global.domain.email.service; + +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import in.koreatech.koin.global.domain.email.form.MailFormData; +import in.koreatech.koin.global.domain.email.model.SesMailSender; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MailService { + + private static final String NO_REPLY_EMAIL_ADDRESS = "no-reply@bcsdlab.com"; + + private final SesMailSender sesMailSender; + private final TemplateEngine templateEngine; + + public void sendMail(String targetEmail, MailFormData mailFormData) { + String mailForm = generateMailForm(mailFormData.getContent(), mailFormData.getFilePath()); + String subject = mailFormData.getSubject(); + sesMailSender.sendMail(NO_REPLY_EMAIL_ADDRESS, targetEmail, subject, mailForm); + } + + private String generateMailForm(Map contents, String fileLocation) { + Context context = new Context(); + contents.forEach(context::setVariable); + return templateEngine.process(fileLocation, context); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/controller/NotificationApi.java b/src/main/java/in/koreatech/koin/global/domain/notification/controller/NotificationApi.java new file mode 100644 index 000000000..23ab5d023 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/controller/NotificationApi.java @@ -0,0 +1,106 @@ +package in.koreatech.koin.global.domain.notification.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COOP; +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.domain.notification.dto.NotificationPermitRequest; +import in.koreatech.koin.global.domain.notification.dto.NotificationStatusResponse; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Notification: 알림", description = "알림 관련 API") +public interface NotificationApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "푸쉬알림 동의 여부 조회") + @GetMapping("/notification") + ResponseEntity checkNotificationStatus( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400"), + @ApiResponse(responseCode = "401"), + @ApiResponse(responseCode = "403"), + @ApiResponse(responseCode = "404"), + } + ) + @Operation(summary = "푸쉬알림 동의") + @PostMapping("/notification") + ResponseEntity permitNotification( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId, + @Valid @RequestBody NotificationPermitRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400"), + @ApiResponse(responseCode = "401"), + @ApiResponse(responseCode = "403"), + @ApiResponse(responseCode = "404"), + } + ) + @Operation(summary = "특정 푸쉬알림 구독") + @PostMapping("/notification/subscribe") + ResponseEntity permitNotificationSubscribe( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId, + @RequestParam(value = "type") NotificationSubscribeType notificationSubscribeType + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400"), + @ApiResponse(responseCode = "401"), + @ApiResponse(responseCode = "403"), + @ApiResponse(responseCode = "404"), + } + ) + @Operation(summary = "푸쉬알림 거절") + @DeleteMapping("/notification") + ResponseEntity rejectNotification( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400"), + @ApiResponse(responseCode = "401"), + @ApiResponse(responseCode = "403"), + @ApiResponse(responseCode = "404"), + } + ) + @Operation(summary = "특정 푸쉬알림 구독 취소") + @DeleteMapping("/notification/subscribe") + ResponseEntity rejectNotificationSubscribe( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId, + @RequestParam(value = "type") NotificationSubscribeType notificationSubscribeType + ); +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/controller/NotificationController.java b/src/main/java/in/koreatech/koin/global/domain/notification/controller/NotificationController.java new file mode 100644 index 000000000..041fb6539 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/controller/NotificationController.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.global.domain.notification.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COOP; +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import org.springframework.http.HttpStatus; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.domain.notification.dto.NotificationPermitRequest; +import in.koreatech.koin.global.domain.notification.dto.NotificationStatusResponse; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; +import in.koreatech.koin.global.domain.notification.service.NotificationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class NotificationController implements NotificationApi { + + private final NotificationService notificationService; + + @GetMapping("/notification") + public ResponseEntity checkNotificationStatus( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ) { + return ResponseEntity.ok(notificationService.checkNotification(userId)); + } + + @PostMapping("/notification") + public ResponseEntity permitNotification( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId, + @Valid @RequestBody NotificationPermitRequest request + ) { + notificationService.permitNotification(userId, request.deviceToken()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/notification/subscribe") + public ResponseEntity permitNotificationSubscribe( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId, + @RequestParam(value = "type") NotificationSubscribeType notificationSubscribeType + ) { + notificationService.permitNotificationSubscribe(userId, notificationSubscribeType); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/notification") + public ResponseEntity rejectNotification( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId + ) { + notificationService.rejectNotification(userId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/notification/subscribe") + public ResponseEntity rejectNotificationSubscribe( + @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId, + @RequestParam(value = "type") NotificationSubscribeType notificationSubscribeType + ) { + notificationService.rejectNotificationByType(userId, notificationSubscribeType); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/controller/NotificationSubscribeTypeConverter.java b/src/main/java/in/koreatech/koin/global/domain/notification/controller/NotificationSubscribeTypeConverter.java new file mode 100644 index 000000000..54a9d0a7f --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/controller/NotificationSubscribeTypeConverter.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.notification.controller; + +import java.util.Arrays; + +import org.springframework.core.convert.converter.Converter; + +import in.koreatech.koin.global.domain.notification.exception.NotificationSubscribeNotFoundException; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; + +public class NotificationSubscribeTypeConverter implements Converter { + + @Override + public NotificationSubscribeType convert(String source) { + return Arrays.stream(NotificationSubscribeType.values()) + .filter(it -> it.name().equalsIgnoreCase(source)) + .findAny() + .orElseThrow( + () -> NotificationSubscribeNotFoundException.withDetail("NotificationSubscribeType: " + source)); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/dto/NotificationPermitRequest.java b/src/main/java/in/koreatech/koin/global/domain/notification/dto/NotificationPermitRequest.java new file mode 100644 index 000000000..c0a118475 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/dto/NotificationPermitRequest.java @@ -0,0 +1,15 @@ +package in.koreatech.koin.global.domain.notification.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public record NotificationPermitRequest( + @Schema(description = "FCM 디바이스 토큰") + @NotBlank String deviceToken +) { + +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/dto/NotificationStatusResponse.java b/src/main/java/in/koreatech/koin/global/domain/notification/dto/NotificationStatusResponse.java new file mode 100644 index 000000000..bc4562158 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/dto/NotificationStatusResponse.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.global.domain.notification.dto; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public record NotificationStatusResponse( + @Schema(description = "푸쉬 알림 동의 여부") + boolean isPermit, + + @Schema(description = "푸쉬 알림 구독 목록") + List subscribes +) { + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + public record NotificationSubscribePermitRequest( + @Schema(description = "구독 타입") + String type, + + @Schema(description = "푸쉬 알림 동의 여부") + boolean isPermit + ) { + + } + + public static NotificationStatusResponse of(boolean isPermit, List subscribes) { + var results = Arrays.stream(NotificationSubscribeType.values()) + .map(type -> new NotificationSubscribePermitRequest( + type.name(), + subscribes.stream().anyMatch(subscribe -> subscribe.getSubscribeType() == type) + )) + .toList(); + return new NotificationStatusResponse(isPermit, results); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/exception/NotificationNotPermitException.java b/src/main/java/in/koreatech/koin/global/domain/notification/exception/NotificationNotPermitException.java new file mode 100644 index 000000000..1638d3683 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/exception/NotificationNotPermitException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.notification.exception; + +import in.koreatech.koin.global.exception.KoinIllegalStateException; + +public class NotificationNotPermitException extends KoinIllegalStateException { + + private static final String DEFAULT_MESSAGE = "푸쉬알림을 동의하지 않았습니다."; + + public NotificationNotPermitException(String message) { + super(message); + } + + public NotificationNotPermitException(String message, String detail) { + super(message, detail); + } + + public static NotificationNotPermitException withDetail(String detail) { + return new NotificationNotPermitException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/exception/NotificationSubscribeNotFoundException.java b/src/main/java/in/koreatech/koin/global/domain/notification/exception/NotificationSubscribeNotFoundException.java new file mode 100644 index 000000000..e7b7312a6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/exception/NotificationSubscribeNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.notification.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class NotificationSubscribeNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 알림 구독입니다."; + + public NotificationSubscribeNotFoundException(String message) { + super(message); + } + + public NotificationSubscribeNotFoundException(String message, String detail) { + super(message, detail); + } + + public static NotificationSubscribeNotFoundException withDetail(String detail) { + return new NotificationSubscribeNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/exception/SubscribeNotFoundException.java b/src/main/java/in/koreatech/koin/global/domain/notification/exception/SubscribeNotFoundException.java new file mode 100644 index 000000000..cbb9e7595 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/exception/SubscribeNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.notification.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class SubscribeNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 알림타입입니다."; + + public SubscribeNotFoundException(String message) { + super(message); + } + + public SubscribeNotFoundException(String message, String detail) { + super(message, detail); + } + + public static SubscribeNotFoundException withDetail(String detail) { + return new SubscribeNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/Notification.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/Notification.java new file mode 100644 index 000000000..4597dff2b --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/Notification.java @@ -0,0 +1,79 @@ +package in.koreatech.koin.global.domain.notification.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.domain.BaseEntity; +import in.koreatech.koin.global.fcm.MobileAppPath; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "notification") +@NoArgsConstructor(access = PROTECTED) +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Enumerated(STRING) + @Column(name = "app_path") + private MobileAppPath mobileAppPath; + + @Column(name = "title") + private String title; + + @Column(name = "message") + private String message; + + @Column(name = "image_url") + private String imageUrl; + + @Enumerated(STRING) + @Column(name = "type") + private NotificationType type; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "users_id", nullable = false) + private User user; + + @Column(name = "is_read", nullable = false) + private boolean isRead = false; + + public Notification( + MobileAppPath appPath, + String title, + String message, + String imageUrl, + NotificationType type, + User user + ) { + this.mobileAppPath = appPath; + this.title = title; + this.message = message; + this.imageUrl = imageUrl; + this.type = type; + this.user = user; + } + + public void read() { + this.isRead = true; + } + + public String getType() { + return type.name().toLowerCase(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java new file mode 100644 index 000000000..fa3fb4795 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java @@ -0,0 +1,41 @@ +package in.koreatech.koin.global.domain.notification.model; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.fcm.MobileAppPath; + +@Component +public class NotificationFactory { + + public Notification generateShopEventCreateNotification( + MobileAppPath path, + String shopName, + String title, + User target + ) { + return new Notification( + path, + "%s의 이벤트가 추가되었어요 🎉".formatted(shopName), + "%s".formatted(title), + null, + NotificationType.MESSAGE, + target + ); + } + + public Notification generateSoldOutNotification( + MobileAppPath path, + String place, + User target + ) { + return new Notification( + path, + "%s코너가 품절되었습니다.".formatted(place), + "다른 식단 보러 가기.", + null, + NotificationType.MESSAGE, + target + ); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribe.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribe.java new file mode 100644 index 000000000..fc6a3057a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribe.java @@ -0,0 +1,45 @@ +package in.koreatech.koin.global.domain.notification.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "notification_subscribe") +@NoArgsConstructor(access = PROTECTED) +public class NotificationSubscribe extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Enumerated(STRING) + @Column(name = "subscribe_type", nullable = false) + private NotificationSubscribeType subscribeType; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder + private NotificationSubscribe(NotificationSubscribeType subscribeType, User user) { + this.subscribeType = subscribeType; + this.user = user; + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java new file mode 100644 index 000000000..7386359bf --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.global.domain.notification.model; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import in.koreatech.koin.global.domain.notification.exception.SubscribeNotFoundException; + +public enum NotificationSubscribeType { + SHOP_EVENT, + DINING_SOLD_OUT, + ; + + @JsonCreator + public static NotificationSubscribeType from(String type) { + return Arrays.stream(values()) + .filter(it -> it.name().equalsIgnoreCase(type)) + .findAny() + .orElseThrow(() -> SubscribeNotFoundException.withDetail("type: " + type)); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationType.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationType.java new file mode 100644 index 000000000..6e2cafdd3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationType.java @@ -0,0 +1,6 @@ +package in.koreatech.koin.global.domain.notification.model; + +public enum NotificationType { + MESSAGE, + ; +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationRepository.java b/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationRepository.java new file mode 100644 index 000000000..69729adb9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.global.domain.notification.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.global.domain.notification.model.Notification; + +public interface NotificationRepository extends Repository { + + Notification save(Notification notification); +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java b/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java new file mode 100644 index 000000000..77f3c7218 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.global.domain.notification.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.global.domain.notification.exception.NotificationSubscribeNotFoundException; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; + +public interface NotificationSubscribeRepository extends Repository { + + NotificationSubscribe save(NotificationSubscribe notificationSubscribe); + + List findAllBySubscribeType(NotificationSubscribeType type); + + Optional findByUserIdAndSubscribeType(Integer userId, NotificationSubscribeType type); + + default NotificationSubscribe getByUserIdAndSubscribeType(Integer userId, NotificationSubscribeType type) { + return findByUserIdAndSubscribeType(userId, type) + .orElseThrow( + () -> NotificationSubscribeNotFoundException.withDetail("userId: " + userId + ", type: " + type) + ); + } + + void deleteByUserIdAndSubscribeType(Integer userId, NotificationSubscribeType type); + + List findAllByUserId(Integer userId); +} diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/global/domain/notification/service/NotificationService.java new file mode 100644 index 000000000..60acba946 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/notification/service/NotificationService.java @@ -0,0 +1,98 @@ +package in.koreatech.koin.global.domain.notification.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.domain.notification.dto.NotificationStatusResponse; +import in.koreatech.koin.global.domain.notification.exception.NotificationNotPermitException; +import in.koreatech.koin.global.domain.notification.model.Notification; +import in.koreatech.koin.global.domain.notification.model.NotificationFactory; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; +import in.koreatech.koin.global.domain.notification.repository.NotificationRepository; +import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.global.fcm.FcmClient; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final UserRepository userRepository; + private final NotificationRepository notificationRepository; + private final FcmClient fcmClient; + private final NotificationFactory notificationFactory; + private final NotificationSubscribeRepository notificationSubscribeRepository; + + public void push(List notifications) { + for (Notification notification : notifications) { + notificationRepository.save(notification); + String deviceToken = notification.getUser().getDeviceToken(); + fcmClient.sendMessage( + deviceToken, + notification.getTitle(), + notification.getMessage(), + notification.getImageUrl(), + notification.getMobileAppPath(), + notification.getType() + ); + } + } + + public void push(Notification notification) { + notificationRepository.save(notification); + String deviceToken = notification.getUser().getDeviceToken(); + fcmClient.sendMessage( + deviceToken, + notification.getTitle(), + notification.getMessage(), + notification.getImageUrl(), + notification.getMobileAppPath(), + notification.getType() + ); + } + + public NotificationStatusResponse checkNotification(Integer userId) { + User user = userRepository.getById(userId); + boolean isPermit = user.getDeviceToken() != null; + List notificationSubscribes = notificationSubscribeRepository.findAllByUserId(userId); + return NotificationStatusResponse.of(isPermit, notificationSubscribes); + } + + @Transactional + public void permitNotification(Integer userId, String deviceToken) { + User user = userRepository.getById(userId); + user.permitNotification(deviceToken); + } + + @Transactional + public void permitNotificationSubscribe(Integer userId, NotificationSubscribeType subscribeType) { + User user = userRepository.getById(userId); + if (user.getDeviceToken() == null) { + throw NotificationNotPermitException.withDetail("user.deviceToken: " + user.getDeviceToken()); + } + if (notificationSubscribeRepository.findByUserIdAndSubscribeType(userId, subscribeType).isPresent()) { + return; + } + NotificationSubscribe notificationSubscribe = NotificationSubscribe.builder() + .user(user) + .subscribeType(subscribeType) + .build(); + notificationSubscribeRepository.save(notificationSubscribe); + } + + @Transactional + public void rejectNotification(Integer userId) { + User user = userRepository.getById(userId); + user.rejectNotification(); + } + + @Transactional + public void rejectNotificationByType(Integer userId, NotificationSubscribeType subscribeType) { + notificationSubscribeRepository.deleteByUserIdAndSubscribeType(userId, subscribeType); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/random/model/CertificateNumberGenerator.java b/src/main/java/in/koreatech/koin/global/domain/random/model/CertificateNumberGenerator.java new file mode 100644 index 000000000..95dd86f71 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/random/model/CertificateNumberGenerator.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.global.domain.random.model; + +import static lombok.AccessLevel.PRIVATE; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = PRIVATE) +public class CertificateNumberGenerator { + + private static final int CERTIFICATION_NUMBER_ORIGIN = 0; + private static final int CERTIFICATION_NUMBER_BOUND = 1_000_000; + + public static String generate() { + return String.format("%06d", + RandomGenerator.createNumber(CERTIFICATION_NUMBER_ORIGIN, CERTIFICATION_NUMBER_BOUND)); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/random/model/RandomGenerator.java b/src/main/java/in/koreatech/koin/global/domain/random/model/RandomGenerator.java new file mode 100644 index 000000000..d0bb31465 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/random/model/RandomGenerator.java @@ -0,0 +1,15 @@ +package in.koreatech.koin.global.domain.random.model; + +import static lombok.AccessLevel.PRIVATE; + +import java.util.concurrent.ThreadLocalRandom; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = PRIVATE) +public class RandomGenerator { + + public static int createNumber(int min, int max) { + return ThreadLocalRandom.current().nextInt(min, max); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/slack/SlackClient.java b/src/main/java/in/koreatech/koin/global/domain/slack/SlackClient.java new file mode 100644 index 000000000..ba5aca0f9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/slack/SlackClient.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.global.domain.slack; + +import static org.springframework.http.MediaType.APPLICATION_JSON; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import in.koreatech.koin.global.domain.slack.model.SlackNotification; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SlackClient { + + private final RestTemplate restTemplate; + + public void sendMessage(SlackNotification slackNotification) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(APPLICATION_JSON); + Map slackMessage = new HashMap<>(); + slackMessage.put("text", slackNotification.getContent()); + slackMessage.put("attachments", List.of( + Map.of("color", SlackNotification.COLOR_GOOD) + )); + HttpEntity> request = new HttpEntity<>(slackMessage, headers); + restTemplate.postForObject( + slackNotification.getSlackUrl(), + request, + String.class + ); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/slack/model/SlackNotification.java b/src/main/java/in/koreatech/koin/global/domain/slack/model/SlackNotification.java new file mode 100644 index 000000000..f571f91f1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/slack/model/SlackNotification.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.global.domain.slack.model; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class SlackNotification { + + public static final String COLOR_GOOD = "good"; + + private final String slackUrl; + private final String content; + + @Builder + private SlackNotification( + String slackUrl, + String text + ) { + this.slackUrl = slackUrl; + this.content = text; + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/slack/model/SlackNotificationFactory.java b/src/main/java/in/koreatech/koin/global/domain/slack/model/SlackNotificationFactory.java new file mode 100644 index 000000000..7f7305977 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/slack/model/SlackNotificationFactory.java @@ -0,0 +1,143 @@ +package in.koreatech.koin.global.domain.slack.model; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.user.model.UserType; + +@Component +public class SlackNotificationFactory { + + private static final String MARKDOWN_ADMIN_PAGE_URL_FORMAT = "<%s|Admin page 바로가기>"; + + private final String adminPageUrl; + private final String ownerEventNotificationUrl; + private final String eventNotificationUrl; + + public SlackNotificationFactory( + @Value("${koin.admin.url}") String adminPageUrl, + @Value("${slack.koin_event_notify_url}") String eventNotificationUrl, + @Value("${slack.koin_owner_event_notify_url}") String ownerEventNotificationUrl + ) { + this.adminPageUrl = adminPageUrl; + this.eventNotificationUrl = eventNotificationUrl; + this.ownerEventNotificationUrl = ownerEventNotificationUrl; + } + + /** + * 사장님 이메일 인증 요청 알림 + */ + public SlackNotification generateOwnerEmailVerificationRequestNotification( + String content + ) { + return SlackNotification.builder() + .slackUrl(ownerEventNotificationUrl) + .text(String.format(""" + `%s(사장님)님이 이메일 인증을 요청하셨습니다.` + """, content) + ) + .build(); + } + + public SlackNotification generateOwnerPhoneVerificationRequestNotification( + String content + ) { + String phoneFormat = String.format("%s-%s-%s" + , content.substring(0, 3) + , content.substring(3, 7) + , content.substring(7, 11)); + return SlackNotification.builder() + .slackUrl(ownerEventNotificationUrl) + .text(String.format(""" + `%s(사장님)님이 문자 인증을 요청하셨습니다.` + """, phoneFormat) + ) + .build(); + } + + /** + * 사장님 이메일 인증 완료 알림 + */ + public SlackNotification generateOwnerEmailVerificationCompleteNotification( + String content + ) { + return SlackNotification.builder() + .slackUrl(ownerEventNotificationUrl) + .text(String.format(""" + `%s(사장님)님이 이메일 인증을 완료했습니다.` + """, content) + ) + .build(); + } + + /** + * 사장님 회원가입 요청 알림 + */ + public SlackNotification generateOwnerRegisterRequestNotification( + String ownerName, + String shopName + ) { + return SlackNotification.builder() + .slackUrl(ownerEventNotificationUrl) + .text(String.format(""" + `%s`님이 가입 승인을 기다리고 있어요! + 가게 정보: `%s` + %s + """, + ownerName, + shopName, + String.format( + MARKDOWN_ADMIN_PAGE_URL_FORMAT, + adminPageUrl + ) + ) + ) + .build(); + } + + /** + * 학생 이메일 인증 요청 알림 + */ + public SlackNotification generateStudentEmailVerificationRequestNotification( + String content + ) { + return SlackNotification.builder() + .slackUrl(eventNotificationUrl) + .text(String.format(""" + `%s(학생)님이 이메일 인증을 요청하셨습니다.` + """, content) + ) + .build(); + } + + /** + * 학생 가입 완료 알림 + */ + public SlackNotification generateStudentRegisterCompleteNotification( + String content + ) { + return SlackNotification.builder() + .slackUrl(eventNotificationUrl) + .text(String.format(""" + `%s(학생)님이 가입하셨습니다.` + """, content) + ) + .build(); + } + + /** + * 유저 탈퇴 알림 + */ + public SlackNotification generateUserDeleteNotification( + String email, + UserType userType + ) { + return SlackNotification.builder() + .slackUrl(eventNotificationUrl) + .text(String.format(""" + `%s(%s)님이 탈퇴하셨습니다.` + """, email, userType.getDescription()) + ) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/controller/ImageUploadDomainEnumConverter.java b/src/main/java/in/koreatech/koin/global/domain/upload/controller/ImageUploadDomainEnumConverter.java new file mode 100644 index 000000000..18404ac96 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/controller/ImageUploadDomainEnumConverter.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.global.domain.upload.controller; + +import java.util.Arrays; + +import org.springframework.core.convert.converter.Converter; + +import in.koreatech.koin.global.domain.upload.exception.ImageUploadDomainNotFoundException; +import in.koreatech.koin.global.domain.upload.model.ImageUploadDomain; + +public class ImageUploadDomainEnumConverter implements Converter { + + @Override + public ImageUploadDomain convert(String source) { + return Arrays.stream(ImageUploadDomain.values()) + .filter(it -> it.name().equalsIgnoreCase(source)) + .findAny() + .orElseThrow(() -> ImageUploadDomainNotFoundException.withDetail("source: " + source)); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java new file mode 100644 index 000000000..050d73a4c --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java @@ -0,0 +1,125 @@ +package in.koreatech.koin.global.domain.upload.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COOP; +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.domain.upload.dto.UploadFileResponse; +import in.koreatech.koin.global.domain.upload.dto.UploadFilesResponse; +import in.koreatech.koin.global.domain.upload.dto.UploadUrlRequest; +import in.koreatech.koin.global.domain.upload.dto.UploadUrlResponse; +import in.koreatech.koin.global.domain.upload.model.ImageUploadDomain; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Upload : 파일 업로드", description = "파일 업로드를 수행한다.") +public interface UploadApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "413", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "415", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "파일을 업로드할 수 있는 URL 생성", description = """ + {domain} 지원 목록 + - items + - lands + - circles + - market + - shops + - members + - owners + - coop + """) + @PostMapping("/{domain}/upload/url") + ResponseEntity getPresignedUrl( + @PathVariable ImageUploadDomain domain, + @RequestBody @Valid UploadUrlRequest request, + @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "413", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "415", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "단건 파일 업로드", description = """ + {domain} 지원 목록 + - items + - lands + - circles + - market + - shops + - members + - owners + """) + @PostMapping( + value = "/{domain}/upload/file", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + ResponseEntity uploadFile( + @Parameter(in = PATH) @PathVariable ImageUploadDomain domain, + @RequestPart MultipartFile multipartFile, + @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "413", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "415", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "다중 파일 업로드", description = """ + {domain} 지원 목록 + - items + - lands + - circles + - market + - shops + - members + - owners + """) + @PostMapping( + value = "/{domain}/upload/files", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + ResponseEntity uploadFiles( + @Parameter(in = PATH) @PathVariable ImageUploadDomain domain, + @RequestPart List files, + @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + ); +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java new file mode 100644 index 000000000..09e6749c0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java @@ -0,0 +1,72 @@ +package in.koreatech.koin.global.domain.upload.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COOP; +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.domain.upload.dto.UploadFileResponse; +import in.koreatech.koin.global.domain.upload.dto.UploadFilesResponse; +import in.koreatech.koin.global.domain.upload.dto.UploadUrlRequest; +import in.koreatech.koin.global.domain.upload.dto.UploadUrlResponse; +import in.koreatech.koin.global.domain.upload.model.ImageUploadDomain; +import in.koreatech.koin.global.domain.upload.service.UploadService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class UploadController implements UploadApi { + + private final UploadService uploadService; + + @PostMapping("/{domain}/upload/url") + public ResponseEntity getPresignedUrl( + @PathVariable ImageUploadDomain domain, + @RequestBody @Valid UploadUrlRequest request, + @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + ) { + var response = uploadService.getPresignedUrl(domain, request); + return ResponseEntity.ok(response); + } + + @PostMapping( + value = "/{domain}/upload/file", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity uploadFile( + @PathVariable ImageUploadDomain domain, + @RequestPart MultipartFile multipartFile, + @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + ) { + var response = uploadService.uploadFile(domain, multipartFile); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + @PostMapping( + value = "/{domain}/upload/files", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity uploadFiles( + @PathVariable ImageUploadDomain domain, + @RequestPart List files, + @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + ) { + var response = uploadService.uploadFiles(domain, files); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadFileResponse.java b/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadFileResponse.java new file mode 100644 index 000000000..ecae6a7fe --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadFileResponse.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.global.domain.upload.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record UploadFileResponse( + @Schema(description = "첨부 파일 URL", example = "https://static.koreatech.in/1.png") + String fileUrl +) { + +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadFilesResponse.java b/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadFilesResponse.java new file mode 100644 index 000000000..d3bad6325 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadFilesResponse.java @@ -0,0 +1,13 @@ +package in.koreatech.koin.global.domain.upload.dto; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(SnakeCaseStrategy.class) +public record UploadFilesResponse( + List fileUrls +) { + +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadUrlRequest.java b/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadUrlRequest.java new file mode 100644 index 000000000..4185d1b21 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadUrlRequest.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.upload.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record UploadUrlRequest( + @Schema(description = "파일 크기", example = "1000") + Integer contentLength, + + @Schema(description = "파일 타입", example = "image/png") + String contentType, + + @Schema(description = "파일 이름", example = "hello.png") + String fileName +) { + +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadUrlResponse.java b/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadUrlResponse.java new file mode 100644 index 000000000..b8b50aec6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/dto/UploadUrlResponse.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.global.domain.upload.dto; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record UploadUrlResponse( + @Schema(description = "파일을 업로드할 수 있는 url", + example = """ + https://bucketname.ap-northeast-2.amazonaws.com/upload/domain/2000/00/00/d4cb13df-cf57-4612-b37d-80ecfa3f4621-1694847132589/image.jpg + ?x-amz-acl=public-read + &X-Amz-Algorithm=AWS4-HMAC-SHA256 + &X-Amz-Date=20000000T000000Z + &X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost + &X-Amz-Expires=7199&X-Amz-Credential=AKIA6BRP3Q6L5PUD5W5Q%2F20230916%2Fap-northeast-2%2Fs3%2Faws4_request + &X-Amz-Signature=796esadfsadfxcv213f851431a88bc16c8db048f322b8993e21e4829c531 + """) + String preSignedUrl, + + @Schema(description = "첨부 파일 URL", example = "https://static.koreatech.in/1.png") + String fileUrl, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "presigned url 만료 일자", example = "2023-01-01 12:34:56") + LocalDateTime expirationDate +) { + +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/exception/ImageUploadDomainNotFoundException.java b/src/main/java/in/koreatech/koin/global/domain/upload/exception/ImageUploadDomainNotFoundException.java new file mode 100644 index 000000000..fa7a63f2f --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/exception/ImageUploadDomainNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.domain.upload.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class ImageUploadDomainNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 이미지 도메인 타입입니다."; + + public ImageUploadDomainNotFoundException(String message) { + super(message); + } + + public ImageUploadDomainNotFoundException(String message, String detail) { + super(message, detail); + } + + public static ImageUploadDomainNotFoundException withDetail(String detail) { + return new ImageUploadDomainNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/model/ImageUploadDomain.java b/src/main/java/in/koreatech/koin/global/domain/upload/model/ImageUploadDomain.java new file mode 100644 index 000000000..5cf898e9b --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/model/ImageUploadDomain.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.global.domain.upload.model; + +import java.util.Arrays; + +import in.koreatech.koin.global.domain.upload.exception.ImageUploadDomainNotFoundException; + +/** + * 이미지 업로드 시 사용되는 도메인별 디렉터리 명 + */ +public enum ImageUploadDomain { + ITEMS, + LANDS, + CIRCLES, + MARKET, + SHOPS, + MEMBERS, + OWNERS, + COOP, + ; + + public static ImageUploadDomain from(String domain) { + return Arrays.stream(values()) + .filter(it -> it.name().equalsIgnoreCase(domain)) + .findAny() + .orElseThrow(() -> ImageUploadDomainNotFoundException.withDetail("domain: " + domain)); + } +} diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/service/UploadService.java b/src/main/java/in/koreatech/koin/global/domain/upload/service/UploadService.java new file mode 100644 index 000000000..abf386872 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/domain/upload/service/UploadService.java @@ -0,0 +1,72 @@ +package in.koreatech.koin.global.domain.upload.service; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.StringJoiner; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import in.koreatech.koin.global.domain.upload.dto.UploadFileResponse; +import in.koreatech.koin.global.domain.upload.dto.UploadFilesResponse; +import in.koreatech.koin.global.domain.upload.dto.UploadUrlRequest; +import in.koreatech.koin.global.domain.upload.dto.UploadUrlResponse; +import in.koreatech.koin.global.domain.upload.model.ImageUploadDomain; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import in.koreatech.koin.global.s3.S3Utils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UploadService { + + private final S3Utils s3Util; + private final Clock clock; + + public UploadUrlResponse getPresignedUrl(ImageUploadDomain domain, UploadUrlRequest request) { + var filePath = generateFilePath(domain.name(), request.fileName()); + return s3Util.getUploadUrl(filePath); + } + + public UploadFileResponse uploadFile(ImageUploadDomain domain, MultipartFile multipartFile) { + try { + var filePath = generateFilePath(domain.name(), Objects.requireNonNull(multipartFile.getOriginalFilename())); + return s3Util.uploadFile(filePath, multipartFile.getBytes()); + } catch (Exception e) { + log.warn("파일 업로드중 문제가 발생했습니다. file: {} \n message: {}", multipartFile, e.getMessage()); + throw new KoinIllegalArgumentException("파일 업로드중 오류가 발생했습니다."); + } + } + + public UploadFilesResponse uploadFiles(ImageUploadDomain domain, List files) { + var response = files.stream() + .map(file -> uploadFile(domain, file)) + .map(UploadFileResponse::fileUrl) + .toList(); + return new UploadFilesResponse(response); + } + + private String generateFilePath(String domainName, String fileNameExt) { + var now = LocalDateTime.now(clock); + StringJoiner uploadPrefix = new StringJoiner("/"); + String[] parts = fileNameExt.split("\\."); + String fileExt = parts[parts.length - 1]; + String fileName = String.join("", Arrays.copyOf(parts, parts.length - 1)); + uploadPrefix.add("upload") + .add(domainName) + .add(String.valueOf(now.getYear())) + .add(String.valueOf(now.getMonth().getValue())) + .add(String.valueOf(now.getDayOfMonth())) + .add(UUID.randomUUID().toString()) + .add(fileName); + return uploadPrefix + "." + fileExt; + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/DataNotFoundException.java b/src/main/java/in/koreatech/koin/global/exception/DataNotFoundException.java new file mode 100644 index 000000000..5980fb5e7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/exception/DataNotFoundException.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.global.exception; + +public abstract class DataNotFoundException extends KoinException { + + protected DataNotFoundException(String message) { + super(message); + } + + protected DataNotFoundException(String message, String detail) { + super(message, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/DuplicationException.java b/src/main/java/in/koreatech/koin/global/exception/DuplicationException.java new file mode 100644 index 000000000..1e9bf5f0d --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/exception/DuplicationException.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.global.exception; + +public abstract class DuplicationException extends KoinException { + + protected DuplicationException(String message) { + super(message); + } + + protected DuplicationException(String message, String detail) { + super(message, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/ErrorResponse.java b/src/main/java/in/koreatech/koin/global/exception/ErrorResponse.java new file mode 100644 index 000000000..d6c8cbae0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/exception/ErrorResponse.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.exception; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import lombok.Getter; + +@Getter +public class ErrorResponse { + + @JsonIgnore + private final int status; + private final String message; + private final String code; + + public ErrorResponse(int status, String message) { + this.status = status; + this.message = message; + this.code = ""; + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/ExternalServiceException.java b/src/main/java/in/koreatech/koin/global/exception/ExternalServiceException.java new file mode 100644 index 000000000..b649fc69c --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/exception/ExternalServiceException.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.global.exception; + +public abstract class ExternalServiceException extends KoinException { + + protected ExternalServiceException(String message) { + super(message); + } + + protected ExternalServiceException(String message, String detail) { + super(message, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..028277b75 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,280 @@ +package in.koreatech.koin.global.exception; + +import java.time.format.DateTimeParseException; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.catalina.connector.ClientAbortException; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.WebUtils; + +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import in.koreatech.koin.global.auth.exception.AuthorizationException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + // 커스텀 예외 + + @ExceptionHandler(KoinException.class) + public ResponseEntity handleIllegalArgumentException( + HttpServletRequest request, + KoinException e + ) { + log.warn(e.getFullMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.BAD_REQUEST, e.getMessage()); + } + + @ExceptionHandler(KoinIllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException( + HttpServletRequest request, + KoinIllegalArgumentException e + ) { + log.warn(e.getFullMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.BAD_REQUEST, e.getMessage()); + } + + @ExceptionHandler(KoinIllegalStateException.class) + public ResponseEntity handleIllegalStateException( + HttpServletRequest request, + KoinIllegalStateException e + ) { + log.warn(e.getFullMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } + + @ExceptionHandler(AuthorizationException.class) + public ResponseEntity handleAuthorizationException( + HttpServletRequest request, + AuthorizationException e + ) { + log.warn(e.getFullMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.FORBIDDEN, e.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException( + HttpServletRequest request, + AuthenticationException e + ) { + log.warn(e.getFullMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.UNAUTHORIZED, e.getMessage()); + } + + @ExceptionHandler(DataNotFoundException.class) + public ResponseEntity handleDataNotFoundException( + HttpServletRequest request, + DataNotFoundException e + ) { + log.warn(e.getFullMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.NOT_FOUND, e.getMessage()); + } + + @ExceptionHandler(DuplicationException.class) + public ResponseEntity handleDataNotFoundException( + HttpServletRequest request, + DuplicationException e + ) { + log.warn(e.getFullMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.CONFLICT, e.getMessage()); + } + + @ExceptionHandler(ExternalServiceException.class) + public ResponseEntity handleExternalServiceException( + HttpServletRequest request, + ExternalServiceException e + ) { + log.warn(e.getFullMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } + + // 표준 예외 및 정의되어 있는 예외 + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleRequestTooFastException( + HttpServletRequest request, + IllegalArgumentException e + ) { + log.warn(e.getMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.BAD_REQUEST, e.getMessage()); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleRequestTooFastException( + HttpServletRequest request, + IllegalStateException e + ) { + log.warn(e.getMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.BAD_REQUEST, e.getMessage()); + } + + @ExceptionHandler(RequestTooFastException.class) + public ResponseEntity handleRequestTooFastException( + HttpServletRequest request, + RequestTooFastException e + ) { + log.warn(e.getMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.CONFLICT, e.getMessage()); + } + + @ExceptionHandler(DateTimeParseException.class) + public ResponseEntity handleDateTimeParseException( + HttpServletRequest request, + DateTimeParseException e + ) { + log.warn(e.getMessage() + e.getParsedString()); + requestLogging(request); + return buildErrorResponse(HttpStatus.BAD_REQUEST, "잘못된 날짜 형식입니다. " + e.getParsedString()); + } + + @ExceptionHandler(UnsupportedOperationException.class) + public ResponseEntity handleUnsupportedOperationException( + HttpServletRequest request, + DateTimeParseException e + ) { + log.warn(e.getMessage() + e.getParsedString()); + requestLogging(request); + return buildErrorResponse(HttpStatus.BAD_REQUEST, "지원하지 않는 API 입니다."); + } + + @ExceptionHandler(ClientAbortException.class) + public ResponseEntity handleClientAbortException( + HttpServletRequest request, + ClientAbortException e + ) { + logger.warn("클라이언트가 연결을 중단했습니다: " + e.getMessage()); + requestLogging(request); + return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException( + HttpServletRequest request, + Exception e + ) { + String errorMessage = e.getMessage(); + String errorFile = e.getStackTrace()[0].getFileName(); + int errorLine = e.getStackTrace()[0].getLineNumber(); + String errorName = e.getClass().getSimpleName(); + String detail = String.format(""" + Exception: *%s* + Location: *%s Line %d* + + ```%s``` + """, + errorName, errorFile, errorLine, errorMessage); + log.error(""" + 서버에서 에러가 발생했습니다. uri: {} {} + {} + """, request.getMethod(), request.getRequestURI(), detail); + requestLogging(request); + return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "서버에서 오류가 발생했습니다."); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest webRequest + ) { + HttpServletRequest request = ((ServletWebRequest)webRequest).getRequest(); + log.warn("검증과정에서 문제가 발생했습니다. uri: {} {}, ", request.getMethod(), request.getRequestURI(), ex); + requestLogging(request); + String errorMessages = ex.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return buildErrorResponse(HttpStatus.BAD_REQUEST, errorMessages); + } + + // 예외 메시지 구성 로직 + + @Override + protected ResponseEntity handleExceptionInternal( + Exception ex, + Object body, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest request + ) { + return buildErrorResponse(HttpStatus.valueOf(statusCode.value()), ex.getMessage()); + } + + private ResponseEntity buildErrorResponse( + HttpStatus httpStatus, + String message + ) { + var response = new ErrorResponse(httpStatus.value(), message); + return ResponseEntity.status(httpStatus).body(response); + } + + private void requestLogging(HttpServletRequest request) { + log.info("request header: {}", getHeaders(request)); + log.info("request query string: {}", getQueryString(request)); + log.info("request body: {}", getRequestBody(request)); + } + + private Map getHeaders(HttpServletRequest request) { + Map headerMap = new HashMap<>(); + Enumeration headerArray = request.getHeaderNames(); + while (headerArray.hasMoreElements()) { + String headerName = headerArray.nextElement(); + headerMap.put(headerName, request.getHeader(headerName)); + } + return headerMap; + } + + private String getQueryString(HttpServletRequest httpRequest) { + String queryString = httpRequest.getQueryString(); + if (queryString == null) { + return " - "; + } + return queryString; + } + + private String getRequestBody(HttpServletRequest request) { + var wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class); + if (wrapper == null) { + return " - "; + } + try { + // body 가 읽히지 않고 예외처리 되는 경우에 캐시하기 위함 + wrapper.getInputStream().readAllBytes(); + byte[] buf = wrapper.getContentAsByteArray(); + if (buf.length == 0) { + return " - "; + } + return new String(buf, wrapper.getCharacterEncoding()); + } catch (Exception e + ) { + return " - "; + } + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/KoinException.java b/src/main/java/in/koreatech/koin/global/exception/KoinException.java new file mode 100644 index 000000000..0c27bd52d --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/exception/KoinException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.exception; + +public abstract class KoinException extends RuntimeException { + + protected final String detail; + + protected KoinException(String message) { + super(message); + this.detail = null; + } + + protected KoinException(String message, String detail) { + super(message); + this.detail = detail; + } + + protected String getFullMessage() { + return String.format("%s %s", getMessage(), detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/KoinIllegalArgumentException.java b/src/main/java/in/koreatech/koin/global/exception/KoinIllegalArgumentException.java new file mode 100644 index 000000000..50706cee5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/exception/KoinIllegalArgumentException.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.global.exception; + +public class KoinIllegalArgumentException extends KoinException { + + private static final String DEFAULT_MESSAGE = "잘못된 요청입니다."; + + public KoinIllegalArgumentException(String message) { + super(message); + } + + public KoinIllegalArgumentException(String message, String detail) { + super(message, detail); + } + + public static KoinIllegalArgumentException withDetail(String detail) { + return new KoinIllegalArgumentException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/KoinIllegalStateException.java b/src/main/java/in/koreatech/koin/global/exception/KoinIllegalStateException.java new file mode 100644 index 000000000..c5b3e9084 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/exception/KoinIllegalStateException.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.global.exception; + +public class KoinIllegalStateException extends KoinException { + + private static final String DEFAULT_MESSAGE = "서버에 문제가 생겼습니다."; + + public KoinIllegalStateException(String message) { + super(message); + } + + public KoinIllegalStateException(String message, String detail) { + super(message, detail); + } + + public static KoinIllegalStateException withDetail(String detail) { + return new KoinIllegalStateException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/RequestTooFastException.java b/src/main/java/in/koreatech/koin/global/exception/RequestTooFastException.java new file mode 100644 index 000000000..3a6944594 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/exception/RequestTooFastException.java @@ -0,0 +1,8 @@ +package in.koreatech.koin.global.exception; + +public class RequestTooFastException extends RuntimeException { + + public RequestTooFastException(String message) { + super(message); + } +} diff --git a/src/main/java/in/koreatech/koin/global/fcm/FcmClient.java b/src/main/java/in/koreatech/koin/global/fcm/FcmClient.java new file mode 100644 index 000000000..00d4cb3c4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/fcm/FcmClient.java @@ -0,0 +1,108 @@ +package in.koreatech.koin.global.fcm; + +import static com.google.firebase.messaging.AndroidConfig.Priority.HIGH; + +import java.util.Map; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import com.google.firebase.messaging.AndroidConfig; +import com.google.firebase.messaging.AndroidNotification; +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.ApnsFcmOptions; +import com.google.firebase.messaging.Aps; +import com.google.firebase.messaging.ApsAlert; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class FcmClient { + + @Async + public void sendMessage( + String targetDeviceToken, + String title, + String content, + String imageUrl, + MobileAppPath path, + String type + ) { + if (targetDeviceToken == null) { + return; + } + log.info("call FcmClient sendMessage: title: {}, content: {}", title, content); + + AndroidConfig androidConfig = generateAndroidConfig(title, content, imageUrl, path, type); + ApnsConfig apnsConfig = generateAppleConfig(title, content, imageUrl, path, type); + + Message message = Message.builder() + .setToken(targetDeviceToken) + .setApnsConfig(apnsConfig) + .setAndroidConfig(androidConfig).build(); + try { + String result = FirebaseMessaging.getInstance().send(message); + log.info("FCM 알림 전송 성공: {}", result); + } catch (Exception e) { + log.warn("FCM 알림 전송 실패", e); + } + } + + private ApnsConfig generateAppleConfig( + String title, + String content, + String imageUrl, + MobileAppPath path, + String type + ) { + return ApnsConfig.builder() + .setAps( + Aps.builder() + .setAlert( + ApsAlert.builder() + .setTitle(title) + .setBody(content) + .build() + ) + .setSound("default") + .setCategory(path.getApple()) + .setMutableContent(true) + .build() + ) + .setFcmOptions( + ApnsFcmOptions.builder() + .setImage(imageUrl) + .build() + ) + .putAllCustomData( + Map.of( + "type", type + ) + ) + .build(); + } + + private AndroidConfig generateAndroidConfig( + String title, + String content, + String imageUrl, + MobileAppPath path, + String type + ) { + AndroidNotification androidNotification = AndroidNotification.builder() + .setTitle(title) + .setBody(content) + .setImage(imageUrl) + .setClickAction(path.getAndroid()) + .build(); + + return AndroidConfig.builder() + .setNotification(androidNotification) + .putData("type", type) + .setPriority(HIGH) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/fcm/FcmConfig.java b/src/main/java/in/koreatech/koin/global/fcm/FcmConfig.java new file mode 100644 index 000000000..139dc54c8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/fcm/FcmConfig.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.global.fcm; + +import java.io.FileNotFoundException; +import java.io.IOException; + +import javax.annotation.PostConstruct; + +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class FcmConfig { + + @PostConstruct + public void initialize() { + try { + ClassPathResource resource = new ClassPathResource("koin-firebase-adminsdk.json"); + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(resource.getInputStream())) + .build(); + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + } + } catch (FileNotFoundException e) { + log.error("파일을 찾을 수 없습니다. ", e); + } catch (IOException e) { + log.error("FCM 인증이 실패했습니다. ", e); + } + } +} diff --git a/src/main/java/in/koreatech/koin/global/fcm/MobileAppPath.java b/src/main/java/in/koreatech/koin/global/fcm/MobileAppPath.java new file mode 100644 index 000000000..6c458a5d6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/fcm/MobileAppPath.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.global.fcm; + +import lombok.Getter; + +@Getter +public enum MobileAppPath { + HOME("home", "home"), + LOGIN("login", "login"), + SHOP("shop", "shop"), + ; + + private final String android; + private final String apple; + + MobileAppPath(String android, String apple) { + this.android = android; + this.apple = apple; + } +} diff --git a/src/main/java/in/koreatech/koin/global/host/ServerURL.java b/src/main/java/in/koreatech/koin/global/host/ServerURL.java new file mode 100644 index 000000000..0449c4e7a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/host/ServerURL.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.global.host; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.Hidden; + +@Hidden // Swagger 문서에 표시하지 않음 +@Target(PARAMETER) +@Retention(RUNTIME) +public @interface ServerURL { + +} diff --git a/src/main/java/in/koreatech/koin/global/host/ServerURLArgumentResolver.java b/src/main/java/in/koreatech/koin/global/host/ServerURLArgumentResolver.java new file mode 100644 index 000000000..7ce6fd13d --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/host/ServerURLArgumentResolver.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.global.host; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +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; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ServerURLArgumentResolver implements HandlerMethodArgumentResolver { + + private final ServerURLContext serverURLContext; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(ServerURL.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return serverURLContext.getServerURL(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/host/ServerURLContext.java b/src/main/java/in/koreatech/koin/global/host/ServerURLContext.java new file mode 100644 index 000000000..b1297051b --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/host/ServerURLContext.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.global.host; + +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +@Component +@RequestScope +public class ServerURLContext { + + private String serverURL; + + public String getServerURL() { + return serverURL; + } + + public void setServerURL(String host) { + this.serverURL = host; + } +} diff --git a/src/main/java/in/koreatech/koin/global/host/ServerURLInterceptor.java b/src/main/java/in/koreatech/koin/global/host/ServerURLInterceptor.java new file mode 100644 index 000000000..04e5d546a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/host/ServerURLInterceptor.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.global.host; + +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ServerURLInterceptor implements HandlerInterceptor { + + private final ServerURLContext serverURLContext; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String serverURL = getServerURL(request); + serverURLContext.setServerURL(serverURL); + return true; + } + + public String getServerURL(HttpServletRequest request) { + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + + return (serverPort != 80 && serverPort != 443) ? + String.format("%s://%s:%d", scheme, serverName, serverPort) : + String.format("%s://%s", scheme, serverName); + } +} diff --git a/src/main/java/in/koreatech/koin/global/ipaddress/IpAddress.java b/src/main/java/in/koreatech/koin/global/ipaddress/IpAddress.java new file mode 100644 index 000000000..69d746f96 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/ipaddress/IpAddress.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.global.ipaddress; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.Hidden; + +@Hidden // Swagger 문서에 표시하지 않음 +@Target(PARAMETER) +@Retention(RUNTIME) +public @interface IpAddress { + +} diff --git a/src/main/java/in/koreatech/koin/global/ipaddress/IpAddressArgumentResolver.java b/src/main/java/in/koreatech/koin/global/ipaddress/IpAddressArgumentResolver.java new file mode 100644 index 000000000..38ed849ef --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/ipaddress/IpAddressArgumentResolver.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.global.ipaddress; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +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; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class IpAddressArgumentResolver implements HandlerMethodArgumentResolver { + + private final NetworkContext networkContext; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(IpAddress.class) && parameter.getParameterType().equals(String.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + + return networkContext.getIpAddress(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/ipaddress/IpAddressInterceptor.java b/src/main/java/in/koreatech/koin/global/ipaddress/IpAddressInterceptor.java new file mode 100644 index 000000000..2c7bf393a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/ipaddress/IpAddressInterceptor.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.global.ipaddress; + +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class IpAddressInterceptor implements HandlerInterceptor { + + private final NetworkContext networkContext; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String clientIP = getClientIP(request); + networkContext.setIpAddress(clientIP); + return true; + } + + public static String getClientIP(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (ip == null) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (ip == null) { + ip = request.getRemoteAddr(); + } + return ip; + } +} diff --git a/src/main/java/in/koreatech/koin/global/ipaddress/NetworkContext.java b/src/main/java/in/koreatech/koin/global/ipaddress/NetworkContext.java new file mode 100644 index 000000000..40c7c385e --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/ipaddress/NetworkContext.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.global.ipaddress; + +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +@Component +@RequestScope +public class NetworkContext { + + private String ipAddress; + + public String getIpAddress() { + return ipAddress; + } + + public void setIpAddress(String ipAddress) { + this.ipAddress = ipAddress; + } +} diff --git a/src/main/java/in/koreatech/koin/global/log/RequestLoggingFilter.java b/src/main/java/in/koreatech/koin/global/log/RequestLoggingFilter.java new file mode 100644 index 000000000..7d15dc41e --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/log/RequestLoggingFilter.java @@ -0,0 +1,79 @@ +package in.koreatech.koin.global.log; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import org.jboss.logging.MDC; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; +import org.springframework.util.PathMatcher; +import org.springframework.util.StopWatch; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RequestLoggingFilter implements Filter { + + public static final String REQUEST_ID = "requestId"; + + private final ObjectProvider pathMatcherProvider; + private final Set setIgnoredUrlPatterns = new HashSet<>(); + + public void setIgnoredUrlPatterns(String... ignoredUrlPatterns) { + this.setIgnoredUrlPatterns.addAll(Arrays.asList(ignoredUrlPatterns)); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws ServletException, IOException { + HttpServletRequest httpRequest = (HttpServletRequest)request; + if (CorsUtils.isPreFlightRequest(httpRequest) || isIgnoredUrl(httpRequest)) { + chain.doFilter(request, response); + return; + } + + var cachedRequest = new ContentCachingRequestWrapper((HttpServletRequest)request); + StopWatch stopWatch = new StopWatch(); + try { + MDC.put(REQUEST_ID, getRequestId(httpRequest)); + stopWatch.start(); + log.info("request start [uri: {} {}]", httpRequest.getMethod(), httpRequest.getRequestURI()); + chain.doFilter(cachedRequest, response); + } finally { + stopWatch.stop(); + log.info("request end [time: {}ms]", stopWatch.getTotalTimeMillis()); + MDC.clear(); + } + } + + private boolean isIgnoredUrl(HttpServletRequest request) { + PathMatcher pathMatcher = this.pathMatcherProvider.getIfAvailable(); + Objects.requireNonNull(pathMatcher); + return setIgnoredUrlPatterns.stream() + .anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI())); + } + + private String getRequestId(HttpServletRequest httpRequest) { + String requestId = httpRequest.getHeader("X-Request-ID"); + if (ObjectUtils.isEmpty(requestId)) { + return UUID.randomUUID().toString().replace("-", ""); + } + return requestId; + } +} diff --git a/src/main/java/in/koreatech/koin/global/log/WebLogConfig.java b/src/main/java/in/koreatech/koin/global/log/WebLogConfig.java new file mode 100644 index 000000000..61fffe0b7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/log/WebLogConfig.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.global.log; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebLogConfig { + + private final RequestLoggingFilter requestLoggingFilter; + + @Bean + public FilterRegistrationBean firstFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + requestLoggingFilter.setIgnoredUrlPatterns( + "/error", + "/favicon.ico", + "/api-docs/**", + "/swagger-ui/**" + ); + registrationBean.setFilter(requestLoggingFilter); + registrationBean.addUrlPatterns("/*"); + registrationBean.setOrder(1); + registrationBean.setName("requestLoggingFilter"); + return registrationBean; + } +} diff --git a/src/main/java/in/koreatech/koin/global/model/Criteria.java b/src/main/java/in/koreatech/koin/global/model/Criteria.java new file mode 100644 index 000000000..ba336d781 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/model/Criteria.java @@ -0,0 +1,67 @@ +package in.koreatech.koin.global.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class Criteria { + private static final Integer DEFAULT_PAGE = 1; + private static final Integer MIN_PAGE = 1; + + private static final Integer DEFAULT_LIMIT = 10; + private static final Integer MIN_LIMIT = 1; + private static final Integer MAX_LIMIT = 50; + + private final int page; + private final int limit; + + public static Criteria of(Integer page, Integer limit) { + return new Criteria(validateAndCalculatePage(page), validateAndCalculateLimit(limit)); + } + + public static Criteria of(Integer page, Integer limit, Integer total) { + return new Criteria(validateAndCalculatePage(page, limit, total), validateAndCalculateLimit(limit)); + } + + private static int validateAndCalculatePage(Integer page) { + if (page == null) { + page = DEFAULT_PAGE; + } + if (page < MIN_PAGE) { + page = MIN_PAGE; + } + page -= 1; // start from 0 + return page; + } + + private static int validateAndCalculatePage(Integer page, Integer limit, Integer total) { + int totalPage = total.equals(0) ? 1 : (int)Math.ceil((double)total / limit); + + if (page == null) { + page = DEFAULT_PAGE; + } + if (page < MIN_PAGE) { + page = MIN_PAGE; + } + if (page > totalPage) { + page = totalPage; + } + + page -= 1; // start from 0 + return page; + } + + private static int validateAndCalculateLimit(Integer limit) { + if (limit == null) { + limit = DEFAULT_LIMIT; + } + if (limit < MIN_LIMIT) { + limit = MIN_LIMIT; + } + if (limit > MAX_LIMIT) { + limit = MAX_LIMIT; + } + return limit; + } +} diff --git a/src/main/java/in/koreatech/koin/global/naver/service/NaverSmsService.java b/src/main/java/in/koreatech/koin/global/naver/service/NaverSmsService.java new file mode 100644 index 000000000..58ae5f7e5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/naver/service/NaverSmsService.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.global.naver.service; + +import org.springframework.stereotype.Service; + +import in.koreatech.koin.global.naver.sms.NaverSmsClient; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NaverSmsService { + + private final NaverSmsClient naverSmsClient; + + public void sendVerificationCode(String certificationCode, String phoneNumber) { + String content = String.format("[KOIN]본인확인 인증번호는 [%s]입니다.", certificationCode); + naverSmsClient.sendMessage(content, phoneNumber); + } +} diff --git a/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsClient.java b/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsClient.java new file mode 100644 index 000000000..f763b1d31 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsClient.java @@ -0,0 +1,109 @@ +package in.koreatech.koin.global.naver.sms; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.util.List; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.commons.codec.binary.Base64; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import in.koreatech.koin.global.naver.sms.NaverSmsSendRequest.InnerMessage; + +@Component +public class NaverSmsClient { + + private final RestTemplate restTemplate; + private final String apiUrl; + private final String serviceKey; + private final String sendNumber; + private final String accessKey; + private final String secretKey; + + public NaverSmsClient( + RestTemplate restTemplate, + @Value("${naver.sms.apiUrl}") String apiUrl, + @Value("${naver.sms.serviceId}") String serviceKey, + @Value("${naver.sms.fromNumber}") String fromNumber, + @Value("${naver.accessKey}") String accessKey, + @Value("${naver.secretKey}") String secretKey + ) { + this.restTemplate = restTemplate; + this.apiUrl = apiUrl; + this.serviceKey = serviceKey; + this.sendNumber = fromNumber; + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + public void sendMessage(String content, String targetPhoneNumber) { + String path = String.format("/sms/v2/services/%s/messages", serviceKey); + + NaverSmsSendRequest request = new NaverSmsSendRequest( + "SMS", + sendNumber, + "COMM", + content, + List.of(new InnerMessage(targetPhoneNumber)) + ); + + String timeStamp = Long.toString(System.currentTimeMillis()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("x-ncp-apigw-timestamp", timeStamp); + headers.set("x-ncp-iam-access-key", accessKey); + headers.set("x-ncp-apigw-signature-v2", makeSignature(path, timeStamp)); + + HttpEntity entity = new HttpEntity<>(request, headers); + + ResponseEntity response = restTemplate.exchange( + apiUrl + path, + HttpMethod.POST, + entity, + NaverSmsResponse.class + ); + + NaverSmsResponse body = response.getBody(); + if (body == null || !"202".equals(body.statusCode())) { + throw new NaverSmsException("문자 호출과정에서 문제가 발생했습니다."); + } + } + + /** + * 네이버 클라우드 API 문서 예시 코드 + */ + private String makeSignature(String url, String time) { + try { + String space = " "; + String newLine = "\n"; + String method = "POST"; + String message = method + + space + + url + + newLine + + time + + newLine + + accessKey; + + SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(UTF_8), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(signingKey); + + byte[] rawHmac = mac.doFinal(message.getBytes(UTF_8)); + + return Base64.encodeBase64String(rawHmac); + } catch (Exception e) { + throw new KoinIllegalArgumentException("잘못된 입력 값입니다."); + } + } +} diff --git a/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsException.java b/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsException.java new file mode 100644 index 000000000..55b97528d --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.naver.sms; + +import in.koreatech.koin.global.exception.ExternalServiceException; + +public class NaverSmsException extends ExternalServiceException { + + private static final String DEFAULT_MESSAGE = "Naver SENS API 호출 과정에서 문제가 발생했습니다."; + + public NaverSmsException(String message) { + super(message); + } + + public NaverSmsException(String message, String detail) { + super(message, detail); + } + + public static NaverSmsException withDetail(String detail) { + return new NaverSmsException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsResponse.java b/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsResponse.java new file mode 100644 index 000000000..3137a99e0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsResponse.java @@ -0,0 +1,15 @@ +package in.koreatech.koin.global.naver.sms; + +import java.time.LocalDateTime; + +import org.springframework.format.annotation.DateTimeFormat; + +public record NaverSmsResponse( + String requestId, + @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS") + LocalDateTime requestTime, + String statusCode, + String statusName +) { + +} diff --git a/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsSendRequest.java b/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsSendRequest.java new file mode 100644 index 000000000..5491074c1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/naver/sms/NaverSmsSendRequest.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.global.naver.sms; + +import java.util.List; + +/** + * 네이버 클라우드 API 문서 + * @param type (SMS | LMS | MMS) + * @param from string + * @param contentType (COMM | AD) + * @param content string + * @param messages list + */ +public record NaverSmsSendRequest( + String type, + String from, + String contentType, + String content, + List messages +) { + + /** + * @param to -를 제외한 숫자만 입력 + */ + public record InnerMessage( + String to + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/global/s3/S3Utils.java b/src/main/java/in/koreatech/koin/global/s3/S3Utils.java new file mode 100644 index 000000000..a676ad4d7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/s3/S3Utils.java @@ -0,0 +1,74 @@ +package in.koreatech.koin.global.s3; + +import java.io.ByteArrayInputStream; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; + +import in.koreatech.koin.global.domain.upload.dto.UploadFileResponse; +import in.koreatech.koin.global.domain.upload.dto.UploadUrlResponse; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Component +public class S3Utils { + + private static final int URL_EXPIRATION_MINUTE = 10; + + private final Clock clock; + private final String bucketName; + private final String domainUrlPrefix; + private final S3Presigner.Builder presignerBuilder; + private final AmazonS3 s3Client; + + public S3Utils( + S3Presigner.Builder presignerBuilder, + AmazonS3 s3Client, + Clock clock, + @Value("${s3.bucket}") String bucketName, + @Value("${s3.custom_domain}") String domainUrlPrefix + ) { + this.presignerBuilder = presignerBuilder; + this.s3Client = s3Client; + this.clock = clock; + this.bucketName = bucketName; + this.domainUrlPrefix = domainUrlPrefix; + } + + public UploadUrlResponse getUploadUrl(String uploadFilePath) { + try (S3Presigner presigner = presignerBuilder.build()) { + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(URL_EXPIRATION_MINUTE)) + .putObjectRequest(builder -> builder + .bucket(bucketName) + .key(uploadFilePath) + .build() + ).build(); + + PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(presignRequest); + return new UploadUrlResponse( + presignedRequest.url().toExternalForm(), + domainUrlPrefix + uploadFilePath, + LocalDateTime.now(clock).plusMinutes(URL_EXPIRATION_MINUTE) + ); + } + } + + public UploadFileResponse uploadFile(String uploadFilePath, byte[] fileData) { + ObjectMetadata metaData = new ObjectMetadata(); + metaData.setContentLength(fileData.length); + s3Client.putObject( + new PutObjectRequest(bucketName, uploadFilePath, new ByteArrayInputStream(fileData), metaData) + .withCannedAcl(CannedAccessControlList.PublicRead)); + return new UploadFileResponse(domainUrlPrefix + uploadFilePath); + } +} diff --git a/src/main/java/in/koreatech/koin/global/validation/UniqueId.java b/src/main/java/in/koreatech/koin/global/validation/UniqueId.java new file mode 100644 index 000000000..e4d63e318 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/UniqueId.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.global.validation; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = UniqueIdValidator.class) +@Target({FIELD, ANNOTATION_TYPE}) +@Retention(RUNTIME) +public @interface UniqueId { + + String message() default "중복된 요소가 존재합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java b/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java new file mode 100644 index 000000000..3ba338312 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.global.validation; + +import java.util.List; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class UniqueIdValidator implements ConstraintValidator> { + + @Override + public void initialize(UniqueId constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(List elements, ConstraintValidatorContext context) { + return elements.stream().distinct().count() == elements.size(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/validation/UniqueUrl.java b/src/main/java/in/koreatech/koin/global/validation/UniqueUrl.java new file mode 100644 index 000000000..ab3564817 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/UniqueUrl.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.global.validation; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = UniqueUrlsValidator.class) +@Target({FIELD, ANNOTATION_TYPE}) +@Retention(RUNTIME) +public @interface UniqueUrl { + + String message() default "중복된 요소가 존재합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java b/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java new file mode 100644 index 000000000..1bd00639b --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.global.validation; + +import java.util.List; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class UniqueUrlsValidator implements ConstraintValidator> { + + @Override + public void initialize(UniqueUrl constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(List elements, ConstraintValidatorContext context) { + return elements.stream().distinct().count() == elements.size(); + } +} diff --git a/src/main/java/in/koreatech/koin/repository/MemberRepository.java b/src/main/java/in/koreatech/koin/repository/MemberRepository.java deleted file mode 100644 index 882a43414..000000000 --- a/src/main/java/in/koreatech/koin/repository/MemberRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package in.koreatech.koin.repository; - -import in.koreatech.koin.domain.Member; -import java.util.List; -import org.springframework.data.repository.Repository; - -public interface MemberRepository extends Repository { - - Member save(Member member); - - List findAllByTrackId(Long id); -} diff --git a/src/main/java/in/koreatech/koin/repository/TrackRepository.java b/src/main/java/in/koreatech/koin/repository/TrackRepository.java deleted file mode 100644 index 16207449e..000000000 --- a/src/main/java/in/koreatech/koin/repository/TrackRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package in.koreatech.koin.repository; - -import in.koreatech.koin.domain.Track; -import java.util.List; -import java.util.Optional; -import org.springframework.data.repository.Repository; - -public interface TrackRepository extends Repository { - - Track save(Track track); - - List findAll(); - - Optional findById(Long id); -} diff --git a/src/main/resources/application-example.yml b/src/main/resources/application-example.yml deleted file mode 100644 index 8b1378917..000000000 --- a/src/main/resources/application-example.yml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/db/migration/V10__add_event_article_thumbnail_images_table.sql b/src/main/resources/db/migration/V10__add_event_article_thumbnail_images_table.sql new file mode 100644 index 000000000..9aa785dbb --- /dev/null +++ b/src/main/resources/db/migration/V10__add_event_article_thumbnail_images_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE if not exists `event_article_thumbnail_images` +( + id INT UNSIGNED AUTO_INCREMENT NOT NULL, + event_id INT UNSIGNED NOT NULL, + thumbnail_image VARCHAR(255), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `FK_EVENT_ARTICLE_THUMBNAIL_IMAGES_ON_EVENT_ARTICLES` FOREIGN KEY (`event_id`) REFERENCES `event_articles` (`id`) +); diff --git a/src/main/resources/db/migration/V11__alter_notification_column_nullable.sql b/src/main/resources/db/migration/V11__alter_notification_column_nullable.sql new file mode 100644 index 000000000..0f17b5526 --- /dev/null +++ b/src/main/resources/db/migration/V11__alter_notification_column_nullable.sql @@ -0,0 +1,14 @@ +alter table `notification` + modify app_path VARCHAR(255) NULL comment '앱 url'; + +alter table `notification` + modify title VARCHAR(255) NULL comment '제목'; + +alter table `notification` + modify message VARCHAR(255) NULL comment '메시지 내용'; + +alter table `notification` + modify image_url VARCHAR(255) NULL comment '이미지 url'; + +alter table `notification` + modify type VARCHAR(255) NULL comment '알림 타입'; diff --git a/src/main/resources/db/migration/V12__alter_menu_categories_update_category_name.sql b/src/main/resources/db/migration/V12__alter_menu_categories_update_category_name.sql new file mode 100644 index 000000000..b8609047b --- /dev/null +++ b/src/main/resources/db/migration/V12__alter_menu_categories_update_category_name.sql @@ -0,0 +1,2 @@ +update `shop_menu_categories` set name = '추천 메뉴' where name = '이벤트 메뉴'; +update `shop_menu_categories` set name = '메인 메뉴' where name = '대표 메뉴'; diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 000000000..84b0a56a3 --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,1047 @@ +create table if not exists activities +( + id int unsigned auto_increment comment 'activities 고유 id' + primary key, + title varchar(255) not null comment '활동명', + description text null comment '활동 설명', + image_urls text null comment '이미지 링크', + date date not null comment '활동 일자', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_bin; + +create table if not exists admins +( + id int unsigned auto_increment comment 'admins 고유 id' + primary key, + user_id int unsigned not null comment '해당 user 고유 id', + grant_user tinyint(1) default 0 not null comment 'user 수정 권한', + grant_callvan tinyint(1) default 0 not null comment '콜벤 수정 권한', + grant_land tinyint(1) default 0 not null comment '복덕방 수정 권한', + grant_community tinyint(1) default 0 not null comment '커뮤니티 수정 권한', + grant_shop tinyint(1) default 0 not null comment '상점 수정 권한', + grant_version tinyint(1) default 0 not null comment '버전 수정 권한', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + grant_market tinyint(1) default 0 not null comment '거래 수정 권한', + grant_circle tinyint(1) default 0 not null comment '동아리 수정 권한', + grant_lost tinyint(1) default 0 not null comment '분실물 수정 권한', + grant_survey tinyint(1) default 0 not null comment '조사 수정 구너한', + grant_bcsdlab tinyint(1) default 0 not null comment 'bcsdlab 홈페이지 수정 권한', + grant_event tinyint(1) default 0 not null comment '이벤트 수정 권한', + constraint admins_user_id_unique + unique (user_id) +) + collate = utf8_unicode_ci; + +create table if not exists article_view_logs +( + id int unsigned auto_increment comment 'article view logs 고유 id' + primary key, + article_id int unsigned not null comment 'article 고유 id', + user_id int unsigned null comment '본 사람 user 고유 id', + expired_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '만료 시간', + ip varchar(45) not null comment 'IP 주소', + constraint article_view_logs_article_id_user_id_unique + unique (article_id, user_id) +) + collate = utf8_unicode_ci; + +create table if not exists articles +( + id int unsigned auto_increment comment 'articlees 고유 id' + primary key, + board_id int unsigned not null comment '게시판의 고유 id', + title varchar(255) not null comment '제목', + content mediumtext not null comment '내용', + user_id int unsigned not null comment '작성자 user 고유 id', + nickname varchar(50) not null comment '작성자 user 닉네임', + hit int unsigned default 0 not null comment '조회수', + ip varchar(45) not null comment 'IP 주소', + is_solved tinyint(1) default 0 not null comment '해결 여부(문의 게시판에 사용)', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + comment_count tinyint unsigned default 0 not null comment '전체 댓글 수', + meta text null comment '등록일자 / 공지사항 주소 등 정보를 String으로 저장', + is_notice tinyint(1) default 0 not null comment '공지사항인지 여부', + notice_article_id int unsigned null comment '공지사항 고유 id', + constraint notice_article_id_UNIQUE + unique (notice_article_id) +) + collate = utf8_unicode_ci; + +create table if not exists boards +( + id int unsigned auto_increment comment 'board 고유 id' + primary key, + tag varchar(10) not null comment '게시판 태그', + name varchar(50) not null comment '게시판 이름', + is_anonymous tinyint(1) default 0 not null comment '익명 닉네임을 사용하는지 여부', + article_count int unsigned default 0 not null comment '게시글 개수', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + is_notice tinyint(1) default 0 not null comment '공지사항인지 여부', + parent_id int unsigned null, + seq int unsigned default 0 not null, + constraint boards_tag_unique + unique (tag) +) + collate = utf8_unicode_ci; + +create table if not exists calendar_universities +( + id int unsigned auto_increment + primary key, + year varchar(10) not null, + start_month varchar(10) not null, + end_month varchar(10) not null, + start_day varchar(10) not null, + end_day varchar(10) not null, + schedule varchar(255) not null, + seq int unsigned not null, + is_continued tinyint(1) default 0 not null, + created_at timestamp default CURRENT_TIMESTAMP not null, + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP, + constraint ux_year_seq + unique (year, seq) +) + collate = utf8_unicode_ci; + +create table if not exists callvan_companies +( + id int unsigned auto_increment comment 'callvan companies 고유 id' + primary key, + name varchar(100) not null comment '콜벤 회사 이름', + phone varchar(100) not null comment '콜벤 회사 전화번호', + pay_card tinyint(1) default 0 not null comment '카드 여부(0:미사용 / 1:사용)', + pay_bank tinyint(1) default 0 not null comment '계좌이체 여부(0: 미사용 / 1:사용)', + hit int unsigned default 0 not null comment '전화 횟수', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint callvan_companies_name_unique + unique (name) +) + collate = utf8_unicode_ci; + +create table if not exists callvan_participants +( + id int unsigned auto_increment comment 'callvan participants 고유 id' + primary key, + room_id int unsigned not null comment 'room 고유 id', + user_id int unsigned not null comment 'user 고유 id', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_unicode_ci; + +create table if not exists callvan_rooms +( + id int unsigned auto_increment comment 'callvan rooms 고유 id' + primary key, + user_id int unsigned not null comment '방장을 맡은 user 고유 id', + departure_place varchar(100) not null comment '출발 장소', + departure_datetime timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '출발 시간', + arrival_place varchar(100) not null comment '도착 장소', + maximum_people int unsigned default 2 not null comment '최대 인원', + current_people int unsigned default 1 not null comment '현재 인원', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_unicode_ci; + +create table if not exists circles +( + id int unsigned auto_increment comment 'circles 고유 id' + primary key, + category varchar(10) not null comment '동아리 카테고리', + name varchar(50) not null comment '동아리 이름', + line_description varchar(255) null comment '한 줄 설명', + logo_url text null comment '로고 이미지 링크', + description text null comment '세부 사항', + link_urls text null comment '외부 링크', + background_img_url text null comment '배경 이미지 링크', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + professor varchar(255) null comment '담당 교수', + location varchar(255) null comment '동아리방 위치', + major_business varchar(255) null comment '주요 사업', + introduce_url varchar(255) null comment '동아리 소개 홈페이지 url', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_unicode_ci; + +create table if not exists comments +( + id int unsigned auto_increment comment 'comment 고유 id' + primary key, + article_id int unsigned not null comment '게시글 고유 id', + content text not null comment '내용', + user_id int unsigned not null comment '답글 user 고유 id', + nickname varchar(50) not null comment '답글 user 닉네임', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_unicode_ci; + +create table if not exists courses +( + id int unsigned auto_increment + primary key, + region varchar(10) not null, + bus_type varchar(15) not null, + is_deleted tinyint(1) default 0 not null +) + collate = utf8mb4_bin; + +create table if not exists dept_infos +( + name varchar(45) not null + primary key, + curriculum_link varchar(255) not null, + is_deleted tinyint(1) default 0 not null, + constraint dept_name_UNIQUE + unique (name) +) + collate = utf8mb4_bin; + +create table if not exists dept_nums +( + dept_name varchar(45) not null, + dept_num varchar(5) not null, + primary key (dept_name, dept_num), + constraint fk_dept_name + foreign key (dept_name) references dept_infos (name) + on update cascade +) + collate = utf8mb4_bin; + +create index idx_dept_num + on dept_nums (dept_num); + +create table if not exists dining_menus +( + id int unsigned auto_increment comment 'dining menus 고유 id' + primary key, + date date not null comment '일자', + type varchar(9) not null comment '식사 유형(아침 , 점심, 저녁)', + place varchar(9) not null comment '종류(양식, 한식..)', + price_card int unsigned null comment '카드 금액', + price_cash int unsigned null comment '현금 금액', + kcal int unsigned null comment '칼로리', + menu text not null comment '메뉴', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint ux_date_type_place + unique (date, type, place) +) + collate = utf8_unicode_ci; + +create table if not exists event_articles +( + id int unsigned auto_increment comment 'event articles 고유 id' + primary key, + shop_id int unsigned not null comment 'Shop(가게) 고유 id', + title varchar(255) not null comment '제목', + event_title varchar(50) not null comment '홍보 문구', + content text not null comment '내용', + user_id int not null comment 'user(작성자) 고유 id', + nickname varchar(50) not null comment '작성자 user 닉네임', + thumbnail varchar(255) null comment '썸네일 이미지', + hit int default 0 not null comment '조회수', + ip varchar(45) not null comment 'IP 주소', + start_date date not null comment '행사 시작일', + end_date date not null comment '행사 마감일', + comment_count tinyint(1) default 0 not null comment '전체 댓글수', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint pk + unique (id) +) + collate = utf8_bin; + +create index idx_is_deleted + on event_articles (is_deleted); + +create index idx_timestamp + on event_articles (created_at); + +create table if not exists event_articles_view_logs +( + id int unsigned auto_increment comment 'event articles view logs 고유 id' + primary key, + event_articles_id int unsigned not null comment 'event articles 고유 id', + user_id int unsigned null comment '게시물을 본 user 고유 id', + expired_at timestamp null comment '만료 일자', + ip varchar(45) not null comment 'IP 주소', + constraint idx_unique + unique (event_articles_id, user_id) +) + collate = utf8_bin; + +create table if not exists event_comments +( + id int unsigned auto_increment comment 'event comments 고유 id' + primary key, + article_id int unsigned not null comment 'article(게시글) 고유 id', + content text not null comment '내용', + user_id int unsigned not null comment '답글 user 고유 id', + nickname varchar(50) not null comment '답글 user 닉네임', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_bin; + +create table if not exists failed_jobs +( + id bigint unsigned auto_increment + primary key, + connection text not null, + queue text not null, + payload longtext not null, + exception longtext not null, + failed_at timestamp default CURRENT_TIMESTAMP not null +) + collate = utf8_unicode_ci; + +create table if not exists faqs +( + id int unsigned auto_increment comment 'faqs 고유 id' + primary key, + question varchar(255) not null comment '질문', + answer text not null comment '답변', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + circle_id int unsigned default 0 not null comment '관련 circle(동아리) id' +) + collate = utf8_unicode_ci; + +create table if not exists holidays +( + id int unsigned auto_increment + primary key, + name varchar(45) not null, + date date not null +) + collate = utf8_bin; + +create table if not exists integrated_assessments +( + id int unsigned auto_increment + primary key, + service_type varchar(255) not null, + evaluated_id int unsigned not null, + score_one int unsigned default 0 not null, + score_two int unsigned default 0 not null, + score_three int unsigned default 0 not null, + score_four int unsigned default 0 not null, + score_five int unsigned default 0 not null, + score_six int unsigned default 0 not null, + score_seven int unsigned default 0 not null, + score_eight int unsigned default 0 not null, + score_nine int unsigned default 0 not null, + score_ten int unsigned default 0 not null, + is_deleted tinyint(1) default 0 not null, + created_at timestamp default CURRENT_TIMESTAMP not null, + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP +) + collate = utf8_unicode_ci; + +create table if not exists item_comments +( + id int unsigned auto_increment comment 'item comments 고유 id' + primary key, + item_id int unsigned not null comment 'item 고유 id', + content text not null comment '내용', + user_id int unsigned not null comment '답글 user 고유 id', + nickname varchar(50) not null comment '답글 user 닉네임', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_unicode_ci; + +create table if not exists item_view_logs +( + id int unsigned auto_increment comment 'item view logs 고유 id' + primary key, + item_id int unsigned not null comment 'item 고유 id', + user_id int unsigned null comment 'user(본 사람) 고유 id', + expired_at timestamp null comment '만료 시간', + ip varchar(45) not null comment 'IP 주소', + constraint item_view_logs_item_id_user_id_unique + unique (item_id, user_id) +) + collate = utf8_unicode_ci; + +create table if not exists items +( + id int unsigned auto_increment comment 'items 고유 id' + primary key, + type int unsigned not null comment '서비스 타입(0:팝니다 / 1:삽니다)', + title varchar(255) not null comment '제목', + content text null comment '내용', + user_id int unsigned not null comment '작성자 user 고유 id', + nickname varchar(50) not null comment '작성자 user 닉네임', + state int unsigned default 0 not null comment '상태 정보(0:판매중 / 1:판매완료 / 2:판매중지)', + price int unsigned default 0 not null comment '가격', + phone varchar(255) null comment '전화번호', + is_phone_open tinyint(1) default 0 not null comment '전화번호 공개 여부(0:비공개 / 1:공개)', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + thumbnail varchar(510) null comment '썸네일 이미지 링크', + hit int unsigned default 0 not null comment '조회수', + ip varchar(45) not null comment 'IP 주소' +) + collate = utf8_unicode_ci; + +create table if not exists land_comments +( + id int unsigned auto_increment comment 'land comments 고유 id' + primary key, + user_id int unsigned not null comment 'user 고유 id', + land_id int unsigned not null comment 'land(원룸) 고유 id', + content text not null comment '내용', + score int unsigned not null comment '평점', + nickname varchar(50) not null comment '댓글 user의 닉네임', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_unicode_ci; + +create table if not exists lands +( + id int unsigned auto_increment comment 'lands 고유 id' + primary key, + name varchar(255) not null comment '건물 이름', + internal_name varchar(50) not null comment '건물 이름 소문자 변환 및 띄어쓰기 제거', + size varchar(255) null comment '방 크기', + room_type varchar(255) null comment '원룸 종류', + latitude varchar(255) null comment '위도', + longitude varchar(255) null comment '경도', + phone varchar(255) null comment '전화번호', + image_urls text null comment '이미지 링크', + address text null comment '주소', + description text null comment '세부 사항', + floor int unsigned null comment '층 수', + deposit varchar(255) null comment '보증금', + monthly_fee varchar(255) null comment '월세', + charter_fee varchar(255) null comment '전세', + management_fee varchar(255) null comment '관리비', + opt_refrigerator tinyint(1) default 0 not null comment '냉장고 보유 여부', + opt_closet tinyint(1) default 0 not null comment '옷장 보유 여부', + opt_tv tinyint(1) default 0 not null comment 'tv 보유 여부', + opt_microwave tinyint(1) default 0 not null comment '전자레인지 보유 여부', + opt_gas_range tinyint(1) default 0 not null comment '가스레인지 보유 여부', + opt_induction tinyint(1) default 0 not null comment '인덕션 보유 여부', + opt_water_purifier tinyint(1) default 0 not null comment '정수기 보유 여부', + opt_air_conditioner tinyint(1) default 0 not null comment '에어컨 보유 여부', + opt_washer tinyint(1) default 0 not null comment '샤워기 보유 여부', + opt_bed tinyint(1) default 0 not null comment '침대 보유 여부', + opt_desk tinyint(1) default 0 not null comment '책상 보유 여부', + opt_shoe_closet tinyint(1) default 0 not null comment '신발장 보유 여부', + opt_electronic_door_locks tinyint(1) default 0 not null comment '전자 도어락 보유 여부', + opt_bidet tinyint(1) default 0 not null comment '비데 보유 여부', + opt_veranda tinyint(1) default 0 not null comment '베란다 보유 여부', + opt_elevator tinyint(1) default 0 not null comment '엘레베이터 보유 여부', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint ux_name + unique (name) +) + collate = utf8_unicode_ci; + +create index ix_internalname + on lands (internal_name); + +create table if not exists lectures +( + id int unsigned auto_increment comment 'lectures 고유 id' + primary key, + semester_date varchar(6) not null comment '학기', + code varchar(10) not null comment '강의 코드', + name varchar(50) not null comment '강의 이름', + grades varchar(2) not null comment '대상 학년', + class varchar(3) not null comment '강의 분반', + regular_number varchar(4) not null comment '수강 인원', + department varchar(30) not null comment '강의 학과', + target varchar(200) not null comment '강의 대상', + professor varchar(30) null comment '강의 교수', + is_english varchar(2) not null comment '영어강의 여부', + design_score varchar(2) not null comment '설계 학점', + is_elearning varchar(2) not null comment '이러닝 여부', + class_time varchar(100) not null comment '강의 시간', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + charset = utf8; + +create table if not exists lost_item_comments +( + id int unsigned auto_increment comment 'lost item comments 고유 id' + primary key, + lost_item_id int unsigned not null comment 'lost item (분실물) 고유 id', + content text not null comment '내용', + user_id int unsigned not null comment '답글 user 고유 id', + nickname varchar(50) not null comment '답글 user의 닉네임', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_unicode_ci; + +create table if not exists lost_item_view_logs +( + id int unsigned auto_increment comment 'lost item view logs 고유 id' + primary key, + lost_item_id int unsigned not null comment 'lost_item(분실물) 고유 id', + user_id int unsigned null comment 'user 고유 id', + expired_at timestamp null comment '만료 시간', + ip varchar(45) not null comment 'IP 주소', + constraint lost_item_view_logs_lost_item_id_user_id_unique + unique (lost_item_id, user_id) +) + collate = utf8_unicode_ci; + +create table if not exists lost_items +( + id int unsigned auto_increment comment 'lost items 고유 id' + primary key, + type int unsigned not null comment '서비스 타입(0:습득 서비스 / 1:분실 서비스)', + title varchar(255) not null comment '제목', + location varchar(255) null comment '분실물 위치', + content text null comment '내용', + user_id int unsigned not null comment '작성자 user 고유 id', + nickname varchar(50) not null comment '작성자 user 닉네임', + state int unsigned default 0 not null comment '상태정보(0: 찾는중 / 1:돌려받음)', + phone varchar(255) null comment '전화 번호', + is_phone_open tinyint(1) default 0 not null comment '전화번호 공개 여부(0:비공개 / 1:공개)', + image_urls text null comment '이미지 링크', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + thumbnail varchar(510) null comment '썸네일 이미지', + hit int unsigned default 0 not null comment '조회수', + ip varchar(45) not null comment 'IP 주소', + comment_count tinyint unsigned default 0 not null comment '전체 댓글 수', + date date null comment '분실 물품 날짜' +) + collate = utf8_unicode_ci; + +create table if not exists members +( + id int auto_increment comment 'members 고유 id' + primary key, + name varchar(50) not null comment '이름', + student_number varchar(255) null comment '학번', + track_id int unsigned not null comment '소속 트랙 고유 id', + position varchar(255) not null comment '직급', + email varchar(100) null comment '이메일', + image_url text null comment '이미지 링크', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_bin; + +create table if not exists migrations +( + id int unsigned auto_increment + primary key, + migration varchar(255) not null, + batch int not null +) + collate = utf8_unicode_ci; + +create table if not exists notice_articles +( + id int unsigned auto_increment comment 'notice articles 고유 id' + primary key, + board_id int unsigned not null comment '게시판 고유 id', + title varchar(255) not null comment '제목', + content mediumtext null comment '내용', + author varchar(50) not null comment '작성자', + hit int unsigned default 0 not null comment '조회수', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + article_num int unsigned not null comment '게시물 번호', + permalink varchar(100) not null comment '기존 게시글 url', + has_notice tinyint(1) default 0 not null comment '기존에 올라왔는지 여부', + registered_at varchar(255) null comment '등록 일자', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint ux_notice_article + unique (board_id, article_num) +) + collate = utf8_unicode_ci; + +create table if not exists owners +( + user_id int not null comment 'user 고유 id' + primary key, + company_registration_number varchar(12) charset utf8 null comment '사업자등록번호', + company_registration_certificate_image_url varchar(255) null, + grant_shop tinyint default 0 null comment '상점 수정 권한', + grant_event tinyint default 0 null comment '이벤트 수정 권한', + constraint company_registration_number_UNIQUE + unique (company_registration_number) +) + collate = utf8_bin; + +create table if not exists password_resets +( + email varchar(255) not null, + token varchar(255) not null, + created_at timestamp null +) + collate = utf8_unicode_ci; + +create index password_resets_email_index + on password_resets (email); + +create table if not exists search_articles +( + id int unsigned auto_increment comment 'search articles 고유 id' + primary key, + table_id int unsigned not null comment '게시판(table) 고유 id', + article_id int unsigned not null comment 'article(게시글) 고유 id', + title varchar(255) not null comment '게시글 제목', + content text null comment '게시글 내용', + user_id int unsigned null comment 'user 고유 id', + nickname varchar(50) not null comment '닉네임', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint idx_unique + unique (table_id, article_id), + constraint pk + unique (id) +) + collate = utf8_bin; + +create index idx_is_deleted + on search_articles (is_deleted); + +create index idx_nickname + on search_articles (nickname, is_deleted, created_at); + +create index idx_timestamp + on search_articles (created_at); + +create table if not exists semester +( + id int unsigned auto_increment + primary key, + semester varchar(10) not null comment '학기', + constraint semester_UNIQUE + unique (semester) +) + collate = utf8_bin; + +create table if not exists shop_categories +( + id int unsigned auto_increment comment 'shop_categories 고유 id' + primary key, + name varchar(255) not null comment '카테고리 이름', + image_url varchar(255) null comment '이미지 URL', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_bin; + +create table if not exists shop_category_map +( + id int unsigned auto_increment comment 'shop_category_map 고유 id' + primary key, + shop_id int unsigned not null comment 'shops 고유 id', + shop_category_id int unsigned not null comment 'shop_categories 고유 id', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint SHOP_ID_AND_SHOP_CATEGORY_ID + unique (shop_id, shop_category_id) +) + collate = utf8_bin; + +create table if not exists shop_images +( + id int unsigned auto_increment comment 'shop_images 고유 id' + primary key, + shop_id int unsigned not null comment 'shops 고유 id', + image_url varchar(255) null comment '이미지 URL', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint SHOP_ID_AND_IMAGE_URL + unique (shop_id, image_url) +) + collate = utf8_bin; + +create table if not exists shop_menu_categories +( + id int unsigned auto_increment comment 'shop_menu_categories 고유 id' + primary key, + shop_id int unsigned not null comment 'shops 고유 id', + name varchar(255) not null comment '카테고리 이름', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_bin; + +create table if not exists shop_menu_category_map +( + id int unsigned auto_increment comment 'shop_menu_category_map 고유 id' + primary key, + shop_menu_id int unsigned not null comment 'shop_menus 고유 id', + shop_menu_category_id int unsigned not null comment 'shop_menu_categories 고유 id', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint SHOP_MENU_ID_AND_SHOP_MENU_CATEGORY_ID + unique (shop_menu_id, shop_menu_category_id) +) + collate = utf8_bin; + +create table if not exists shop_menu_details +( + id int unsigned auto_increment comment 'shop_menu_details 고유 id' + primary key, + shop_menu_id int unsigned not null comment 'shop_menus 고유 id', + `option` varchar(255) null comment '옵션 이름', + price int unsigned not null comment '가격', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint SHOP_MENU_ID_AND_OPTION_AND_PRICE + unique (shop_menu_id, `option`, price) +) + collate = utf8_bin; + +create table if not exists shop_menu_images +( + id int unsigned auto_increment comment 'shop_menu_images 고유 id' + primary key, + shop_menu_id int unsigned not null comment 'shop_menus 고유 id', + image_url varchar(255) not null comment '이미지 URL', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint SHOP_MENU_ID_AND_IMAGE_URL + unique (shop_menu_id, image_url) +) + collate = utf8_bin; + +create table if not exists shop_menus +( + id int unsigned auto_increment comment 'shop_menus 고유 id' + primary key, + shop_id int unsigned not null comment 'shop 고유 id', + name varchar(255) not null comment '메뉴 이름', + description varchar(255) null comment '메뉴 구성', + is_hidden tinyint(1) default 0 not null comment '숨김 여부', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_unicode_ci; + +create table if not exists shop_opens +( + id int unsigned auto_increment comment 'shop_open 고유 id' + primary key, + shop_id int unsigned not null comment 'shops 고유 id', + day_of_week varchar(10) not null comment '요일', + closed tinyint(1) not null comment '휴무 여부', + open_time varchar(10) null comment '오픈 시간', + close_time varchar(10) null comment '마감 시간', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_bin; + +create table if not exists shop_view_logs +( + id int unsigned auto_increment comment 'shop_view_logs 고유 id' + primary key, + shop_id int unsigned not null comment '가게 고유 id', + user_id int unsigned null comment 'user 고유 id', + expired_at timestamp null comment '만료 시간', + ip varchar(45) not null comment '조회한 IP 주소' +) + collate = utf8_bin; + +create table if not exists shops +( + id int unsigned auto_increment comment 'shops 고유 id' + primary key, + owner_id int null comment 'owner 고유 id', + name varchar(50) not null comment '가게 이름', + internal_name varchar(50) not null comment '가게 이름을 소문자로 변경하고 띄어쓰기 제거', + chosung varchar(3) null comment '가게 이름 앞자리 1글자의 초성', + phone varchar(50) null comment '전화 번호', + address text null comment '주소', + description text null comment '세부 사항', + delivery tinyint(1) default 0 not null comment '배달 가능 여부', + delivery_price int unsigned default 0 not null comment '배달 금액', + pay_card tinyint(1) default 0 not null comment '카드 가능 여부', + pay_bank tinyint(1) default 0 not null comment '계좌이체 가능 여부', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + is_event tinyint(1) default 0 not null comment '이벤트 진행 여부', + remarks text null comment '이벤트 상세내용 등 부가내용', + hit int unsigned default 0 not null comment '조회수' +) + charset = utf8mb4; + +create index ix_internalname + on shops (internal_name); + +create table if not exists students +( + user_id int not null comment 'user 고유 id' + primary key, + anonymous_nickname varchar(255) null comment '익명 닉네임', + student_number varchar(255) null comment '학번', + major varchar(50) null comment '전공', + identity smallint null comment '신원(0: 학생, 1: 대학원생)', + is_graduated tinyint(1) null comment '졸업 여부', + constraint anonymous_nickname_UNIQUE + unique (anonymous_nickname) +) + collate = utf8_bin; + +create table if not exists survey_answers +( + id int unsigned auto_increment + primary key, + survey_id int unsigned not null, + question_id int unsigned not null, + content text not null, + is_deleted tinyint(1) default 0 not null, + created_at timestamp default CURRENT_TIMESTAMP not null, + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP +) + collate = utf8_unicode_ci; + +create table if not exists survey_questions +( + id int unsigned auto_increment + primary key, + survey_id int unsigned not null, + `order` int unsigned not null, + order_sub int unsigned default 0 not null, + type varchar(255) not null, + title varchar(255) not null, + choices text not null, + is_deleted tinyint(1) default 0 not null, + is_required tinyint(1) default 0 not null, + created_at timestamp default CURRENT_TIMESTAMP not null, + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP +) + collate = utf8_unicode_ci; + +create table if not exists survey_view_logs +( + id int unsigned auto_increment + primary key, + survey_id int unsigned not null, + expired_at timestamp null, + ip varchar(45) not null, + constraint survey_view_logs_survey_id_ip_unique + unique (survey_id, ip) +) + collate = utf8_unicode_ci; + +create table if not exists surveys +( + id int unsigned auto_increment + primary key, + user_id int unsigned not null, + title varchar(255) not null, + description varchar(255) null, + conclusion varchar(255) null, + is_answer_open tinyint(1) default 0 not null, + started_at timestamp null, + finished_at timestamp null, + state tinyint default 0 not null, + is_deleted tinyint(1) default 0 not null, + created_at timestamp default CURRENT_TIMESTAMP not null, + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP, + hit int unsigned default 0 not null, + tag varchar(255) null, + is_recruit tinyint(1) default 0 not null +) + collate = utf8_unicode_ci; + +create table if not exists tech_stacks +( + id int unsigned auto_increment comment 'tech_stacks 고유 id' + primary key, + image_url text null comment '이미지 링크', + name varchar(50) not null comment '기술 스택 명', + description varchar(100) null comment '기술 스택 설명', + track_id int unsigned not null comment 'track 고유 id', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_bin; + +create table if not exists temp_articles +( + id int unsigned auto_increment comment 'temp_articles 고유 id' + primary key, + title varchar(255) not null comment '제목', + content text null comment '내용', + nickname varchar(50) not null comment '작성자 닉네임', + password text not null comment '익명게시글 비밀번호', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '작성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + hit int unsigned default 0 not null comment '조회수', + comment_count tinyint unsigned default 0 not null comment '전체 댓글 수' +) + collate = utf8_unicode_ci; + +create table if not exists temp_comments +( + id int unsigned auto_increment comment 'temp_comments 고유 id' + primary key, + article_id int unsigned not null comment 'article(게시글) 고유 id', + content text not null comment '내용', + nickname varchar(50) not null comment '작성자 닉네임', + password text not null comment '익명댓글 비밀번호', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_unicode_ci; + +create table if not exists test +( + id int unsigned auto_increment + primary key, + portal_account varchar(50) not null, + constraint users_portal_account_unique + unique (portal_account) +) + collate = utf8_unicode_ci; + +create table if not exists timetables +( + id int unsigned auto_increment comment 'timetables 고유 id' + primary key, + user_id int unsigned not null comment 'user 고유 id', + semester_id int unsigned not null comment '학기 고유 id', + code varchar(10) null comment '과목 코드', + class_title varchar(50) not null comment '과목 명', + class_time varchar(100) not null comment '과목 시간', + class_place varchar(30) null comment '수업 장소', + professor varchar(30) null comment '담당 교수', + grades varchar(2) not null comment '학점', + lecture_class varchar(3) null comment '분반', + target varchar(200) null comment '학년 대상', + regular_number varchar(4) null comment '수강 정원', + design_score varchar(4) null comment '설계', + department varchar(30) null comment '학부', + memo varchar(200) null comment '사용자용 메모', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_bin; + +create table if not exists tracks +( + id int unsigned auto_increment comment 'tracks 테이블 고유 id' + primary key, + name varchar(50) not null comment '트랙명', + headcount int unsigned default 0 not null comment '인원수', + is_deleted tinyint(1) default 0 not null comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자' +) + collate = utf8_bin; + +create table if not exists users +( + id int unsigned auto_increment comment 'users 테이블 고유 id' + primary key, + password text not null comment '비밀번호', + nickname varchar(50) null comment '닉네임', + name varchar(50) null comment '이름', + phone_number varchar(255) null comment '휴대 전화 번호', + user_type varchar(255) not null comment '유저 타입(Students or Owners)', + email varchar(100) not null comment '학교 email', + gender int unsigned null comment '성별', + is_authed tinyint(1) default 0 not null comment '인증 여부', + last_logged_at timestamp null comment '최근 로그인 일자', + profile_image_url varchar(255) null comment '프로필 이미지 s3 url', + is_deleted tinyint(1) default 0 not null comment '탈퇴 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '회원가입 일자(생성 일자)', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + auth_token varchar(255) null comment '이메일 인증 토큰', + auth_expired_at varchar(255) null comment '이메일 인증 토큰 만료 시간', + reset_token varchar(255) null comment '비밀번호 초기화 토큰', + reset_expired_at varchar(255) null comment '비밀번호 초기화 토큰 만료 시간', + constraint email_UNIQUE + unique (email), + constraint nickname_UNIQUE + unique (nickname) +) + collate = utf8_unicode_ci; + +create table if not exists owner_attachments +( + id int unsigned auto_increment + primary key, + owner_id int unsigned not null, + url text not null, + is_deleted tinyint(1) default 0 not null, + created_at timestamp default CURRENT_TIMESTAMP not null, + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP, + constraint owner_shop_attachment_fk_owner_id + foreign key (owner_id) references users (id) +) + collate = utf8_bin; + +create table if not exists users_owners +( + id int unsigned auto_increment comment 'users_owners 고유 id' + primary key, + user_id int unsigned not null comment 'users 중에 owners에 해당하는 user_id', + email varchar(125) null comment '연락 가능한 이메일', + constraint email_UNIQUE + unique (email), + constraint user_id_UNIQUE + unique (user_id), + constraint users_owners_fk_user_id + foreign key (user_id) references users (id) + on delete cascade +) + collate = utf8_bin; + +create table if not exists versions +( + id int unsigned auto_increment comment 'versions 테이블 고유 id' + primary key, + version varchar(255) not null comment '버전 명 (예시 : 1.1.0)', + type varchar(255) null, + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + constraint versions_type_unique + unique (type) +) + collate = utf8_unicode_ci; diff --git a/src/main/resources/db/migration/V2__add_notification.sql b/src/main/resources/db/migration/V2__add_notification.sql new file mode 100644 index 000000000..072f1ab3c --- /dev/null +++ b/src/main/resources/db/migration/V2__add_notification.sql @@ -0,0 +1,16 @@ +ALTER TABLE `users` ADD COLUMN device_token VARCHAR(255); + +CREATE TABLE `notification`( + id BIGINT AUTO_INCREMENT NOT NULL comment '고유 id', + url VARCHAR(255) NOT NULL comment '앱 url', + title VARCHAR(255) NOT NULL comment '제목', + message VARCHAR(255) NOT NULL comment '메시지 내용', + image_url VARCHAR(255) NOT NULL comment '이미지 url', + `type` VARCHAR(255) NOT NULL comment '알림 타입', + users_id INT UNSIGNED NOT NULL comment '유저 id', + is_read TINYINT(1) NOT NULL comment '읽음 여부', + created_at DATETIME NULL comment '생성 일자', + updated_at DATETIME NULL comment '수정 일자', + PRIMARY KEY(`id`), + CONSTRAINT `FK_NOTIFICATION_ON_USER FOREIGN KEY` FOREIGN KEY (`users_id`) REFERENCES `users` (`id`) +); diff --git a/src/main/resources/db/migration/V3__alter_diningmenus_soldout.sql b/src/main/resources/db/migration/V3__alter_diningmenus_soldout.sql new file mode 100644 index 000000000..bfed0858d --- /dev/null +++ b/src/main/resources/db/migration/V3__alter_diningmenus_soldout.sql @@ -0,0 +1 @@ +ALTER TABLE `dining_menus` ADD COLUMN sold_out BOOLEAN NOT NULL DEFAULT FALSE diff --git a/src/main/resources/db/migration/V4__alter_diningmenus_imageupload.sql b/src/main/resources/db/migration/V4__alter_diningmenus_imageupload.sql new file mode 100644 index 000000000..874d1b0d0 --- /dev/null +++ b/src/main/resources/db/migration/V4__alter_diningmenus_imageupload.sql @@ -0,0 +1,2 @@ +ALTER TABLE `dining_menus` ADD COLUMN image_url varchar(255) AFTER menu; + diff --git a/src/main/resources/db/migration/V5__add_notification_subscribe_table.sql b/src/main/resources/db/migration/V5__add_notification_subscribe_table.sql new file mode 100644 index 000000000..8193333e5 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_notification_subscribe_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE `notification_subscribe` +( + id BIGINT AUTO_INCREMENT NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + subscribe_type VARCHAR(255) NOT NULL, + user_id INT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `FK_NOTIFICATION_SUBSCRIBE_ON_USER` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +); diff --git a/src/main/resources/db/migration/V6__alter_diningmenus_updated.sql b/src/main/resources/db/migration/V6__alter_diningmenus_updated.sql new file mode 100644 index 000000000..77682be49 --- /dev/null +++ b/src/main/resources/db/migration/V6__alter_diningmenus_updated.sql @@ -0,0 +1 @@ +ALTER TABLE `dining_menus` ADD COLUMN is_changed BOOLEAN NOT NULL DEFAULT FALSE diff --git a/src/main/resources/db/migration/V7__alter_diningmenus_change_field_type.sql b/src/main/resources/db/migration/V7__alter_diningmenus_change_field_type.sql new file mode 100644 index 000000000..c67ba00be --- /dev/null +++ b/src/main/resources/db/migration/V7__alter_diningmenus_change_field_type.sql @@ -0,0 +1,11 @@ +ALTER TABLE `dining_menus` + MODIFY COLUMN sold_out BOOLEAN NULL, + MODIFY COLUMN is_changed BOOLEAN NULL; + +UPDATE `dining_menus` +SET sold_out = NULL, + is_changed = NULL; + +ALTER TABLE `dining_menus` + MODIFY COLUMN sold_out DATETIME NULL, + MODIFY COLUMN is_changed DATETIME NULL; diff --git a/src/main/resources/db/migration/V8__alter_notificaion_change_column_name.sql b/src/main/resources/db/migration/V8__alter_notificaion_change_column_name.sql new file mode 100644 index 000000000..8fc960d7c --- /dev/null +++ b/src/main/resources/db/migration/V8__alter_notificaion_change_column_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE `notification` + CHANGE COLUMN url app_path VARCHAR(255); diff --git a/src/main/resources/db/migration/V9__alter_notificaion_modify_auditing.sql b/src/main/resources/db/migration/V9__alter_notificaion_modify_auditing.sql new file mode 100644 index 000000000..0e9c7352e --- /dev/null +++ b/src/main/resources/db/migration/V9__alter_notificaion_modify_auditing.sql @@ -0,0 +1,11 @@ +alter table `notification` + modify created_at TIMESTAMP not null comment '생성 일자'; + +alter table `notification` + modify updated_at TIMESTAMP not null comment '수정 일자'; + +alter table `notification_subscribe` + modify created_at TIMESTAMP not null comment '생성 일자'; + +alter table `notification_subscribe` + modify updated_at TIMESTAMP not null comment '수정 일자'; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..91e2dbaab --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + ${consoleLogPattern} + + + + + + + + + + + + + + ${consoleLogPattern} + + + + + ${SLACK_WEBHOOK_URI} + + ${slackLogPattern} + + true + + + + + + ERROR + + + + + + + + + + + + + + + + ${infoLogPath}/info.log + + 7 + 1GB + 2GB + ${infoLogPath}/${fileNamePattern} + + + ${defaultLogPattern} + + + INFO + ACCEPT + NEUTRAL + + + WARN + ACCEPT + NEUTRAL + + + ERROR + ACCEPT + DENY + + + + + ${errorLogPath}/error.log + + 7 + 1GB + 2GB + ${errorLogPath}/${fileNamePattern} + + + ${defaultLogPattern} + + + ERROR + ACCEPT + DENY + + + + + + + + + diff --git a/src/main/resources/mail/change_password_config.html b/src/main/resources/mail/change_password_config.html new file mode 100644 index 000000000..62c79f7d2 --- /dev/null +++ b/src/main/resources/mail/change_password_config.html @@ -0,0 +1,64 @@ + + + + + + + 코인 이메일 인증 폼 + + + + + + + + + + + + + + +
+ Creating Email Magic +
+ + + + + + + + +
+ 비밀번호 변경 안내 +
+ 안녕하세요.

+ 한국기술교육대학교 커뮤니티 코인 운영팀입니다.
+
+ 패스워드를 입력해주세요.(6자이상 18자 이하의 특수문자를 포함)

+
+ + 비밀번호 입력 :

+ 비밀번호 확인 :

+

+
+
+
+ + + + +
+ Copyright BCSD Lab, TEAM_KAP All rights reserved. +
+
+ + + + + diff --git a/src/main/resources/mail/error_config.html b/src/main/resources/mail/error_config.html new file mode 100644 index 000000000..78272d412 --- /dev/null +++ b/src/main/resources/mail/error_config.html @@ -0,0 +1,15 @@ + + + + + 코인 이메일 인증 폼 + + + + + + diff --git a/src/main/resources/mail/owner_change_password_certificate_number.html b/src/main/resources/mail/owner_change_password_certificate_number.html new file mode 100644 index 000000000..11846548e --- /dev/null +++ b/src/main/resources/mail/owner_change_password_certificate_number.html @@ -0,0 +1,58 @@ + + + + + + 코인 이메일 인증 폼 + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ 코인에서 + 비밀번호 찾기 인증번호 + 를
+ 안내해 드립니다. +
+
+ + [[${emailAddress}]] + 님 안녕하세요, + +
+ 한국기술교육대학교 커뮤니티 코인 운영팀입니다.
+ 고객님께서 [[${year}]]년 [[${month}]]월 [[${day}]]일 [[${hour}]]시 [[${minute}]]분경 요청하신 인증번호입니다. +
+ 비밀번호 찾기 인증번호 +

+ [[${certificationCode}]] +

+
+ + + diff --git a/src/main/resources/mail/owner_register_certificate_number.html b/src/main/resources/mail/owner_register_certificate_number.html new file mode 100644 index 000000000..cdc293880 --- /dev/null +++ b/src/main/resources/mail/owner_register_certificate_number.html @@ -0,0 +1,57 @@ + + + + + 코인 이메일 인증 폼 + + + + + + + + + + + + + + +
+ Creating Email Magic +
+ + + + + + + +
+ 사장님 회원가입 이메일 인증 +
+ 안녕하세요.

+ 한국기술교육대학교 커뮤니티 코인 운영팀입니다.
+
+ 회원가입 페이지로 돌아가 아래에 있는 인증번호를 입력해주세요.


+ +

+ [[${certificationCode}]] +

+
+
+ + + + + +
+ Copyright BCSD Lab All rights reserved. +
+
+ + diff --git a/src/main/resources/mail/student_change_password_certificate_button.html b/src/main/resources/mail/student_change_password_certificate_button.html new file mode 100644 index 000000000..9617aba32 --- /dev/null +++ b/src/main/resources/mail/student_change_password_certificate_button.html @@ -0,0 +1,63 @@ + + + + + 코인 이메일 인증 폼 + + + + + + + + + + + + + + +
+ Creating Email Magic +
+ + + + + + + +
+ 계정 비밀번호 변경 인증 +
+ 안녕하세요.

+ 한국기술교육대학교 커뮤니티 코인 운영팀입니다.
+
+ 비밀번호를 변경하려면 아래 버튼을 클릭하세요.

+ + 비밀번호 + 변경하기 +
+
+ + + + + +
+ Copyright BCSD Lab All rights reserved. +
+
+ + diff --git a/src/main/resources/mail/student_register_certificate_number.html b/src/main/resources/mail/student_register_certificate_number.html new file mode 100644 index 000000000..907569447 --- /dev/null +++ b/src/main/resources/mail/student_register_certificate_number.html @@ -0,0 +1,61 @@ + + + + + + 코인 회원가입 인증 + + + + + + + + + + + + + +
+ Creating Email Magic +
+ + + + + + + + +
+ 학교 이메일 주소 인증 +
+ 안녕하세요.

+ 한국기술교육대학교 커뮤니티 코인 운영팀입니다.
+
+ 회원 가입을 위해 이메일 주소를 인증해주세요.

+ +

+ + 이메일 주소 인증 + + +

+ 이 메일은 10시간 뒤에 만료됩니다. 만료시 회원가입을 다시 시도해주세요.
+
+
+ + + + +
+ Copyright BCSD Lab, TEAM_KAP All rights reserved. +
+
+ + diff --git a/src/main/resources/mail/success_register_config.html b/src/main/resources/mail/success_register_config.html new file mode 100644 index 000000000..978ed160e --- /dev/null +++ b/src/main/resources/mail/success_register_config.html @@ -0,0 +1,50 @@ + + + + + 코인 이메일 인증 폼 + + + + + + + + + + + + + + + +
+ Creating Email Magic +
+ + + + + + + +
+ 회원 가입 완료 안내 +
+ 안녕하세요.

+ 한국기술교육대학교 커뮤니티 코인 운영팀입니다.
+
+ 회원 가입을 위한 인증이 완료되었습니다.

+
+
+ + + + +
+ Copyright BCSD Lab, TEAM_KAP All rights reserved. +
+
+ + diff --git a/src/main/resources/static/js/password.check.js b/src/main/resources/static/js/password.check.js new file mode 100644 index 000000000..58eee4a12 --- /dev/null +++ b/src/main/resources/static/js/password.check.js @@ -0,0 +1,48 @@ +function validatePasswordFormat(password) { + var regExp = new RegExp( + '^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{6,18}$', 'g'); + if (!regExp.exec(password)) { + return false; + } + return true; +} + +var $userPw = $('#password'); +var $userPwConfirm = $('#password_confirm'); +var $requestUrl = $('#requestUrl'); + +$("#submitButton").click(function () { + var userPw = $.trim($userPw.val()); + var userPwConfirm = $.trim($userPwConfirm.val()); + + if (userPw !== userPwConfirm) { + alert('패스워드가 일치하지 않습니다. 다시 입력해주세요.'); + return false; + } + + if (validatePasswordFormat(userPw) === false) { + alert('특수문자를 포함한 영어와 숫자 6~18 자리를 입력하세요'); + return false; + } + + $.ajax({ + url: $requestUrl.val(), + type: 'post', + contentType: 'application/json; charset=utf-8', + data: JSON.stringify({password: sha256(userPw)}), + success: function (response) { + alert('비밀번호 변경 성공!\n변경된 비밀번호로 로그인해주세요.'); + location.href = '//koreatech.in'; + }, + error: function (jqXHR, textStatus, errorThrown) { + if (jqXHR.status === 401) { + // 401 에러에 대한 특정 로직 + alert('유효시간이 만료되었습니다.\n메일을 재전송하여 진행해주세요.'); + } else { + // 기타 에러 처리 + alert('서버와의 통신 중 오류가 발생했습니다.'); + } + } + }); + return false; +}); diff --git a/src/main/resources/static/js/sha256.min.js b/src/main/resources/static/js/sha256.min.js new file mode 100644 index 000000000..becbd72b0 --- /dev/null +++ b/src/main/resources/static/js/sha256.min.js @@ -0,0 +1 @@ +var sha256=function a(b){function c(a,b){return a>>>b|a<<32-b}for(var d,e,f=Math.pow,g=f(2,32),h="length",i="",j=[],k=8*b[h],l=a.h=a.h||[],m=a.k=a.k||[],n=m[h],o={},p=2;64>n;p++)if(!o[p]){for(d=0;313>d;d+=p)o[d]=p;l[n]=f(p,.5)*g|0,m[n++]=f(p,1/3)*g|0}for(b+="\x80";b[h]%64-56;)b+="\x00";for(d=0;d>8)return;j[d>>2]|=e<<(3-d)%4*8}for(j[j[h]]=k/g|0,j[j[h]]=k,e=0;ed;d++){var s=q[d-15],t=q[d-2],u=l[0],v=l[4],w=l[7]+(c(v,6)^c(v,11)^c(v,25))+(v&l[5]^~v&l[6])+m[d]+(q[d]=16>d?q[d]:q[d-16]+(c(s,7)^c(s,18)^s>>>3)+q[d-7]+(c(t,17)^c(t,19)^t>>>10)|0),x=(c(u,2)^c(u,13)^c(u,22))+(u&l[1]^u&l[2]^l[1]&l[2]);l=[w+x|0].concat(l),l[4]=l[4]+w|0}for(d=0;8>d;d++)l[d]=l[d]+r[d]|0}for(d=0;8>d;d++)for(e=3;e+1;e--){var y=l[d]>>8*e&255;i+=(16>y?0:"")+y.toString(16)}return i}; \ No newline at end of file diff --git a/src/test/java/in/koreatech/koin/AcceptanceTest.java b/src/test/java/in/koreatech/koin/AcceptanceTest.java new file mode 100644 index 000000000..f828c6b31 --- /dev/null +++ b/src/test/java/in/koreatech/koin/AcceptanceTest.java @@ -0,0 +1,116 @@ +package in.koreatech.koin; + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +import java.time.Clock; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.utility.DockerImageName; + +import in.koreatech.koin.config.TestJpaConfiguration; +import in.koreatech.koin.config.TestTimeConfig; +import in.koreatech.koin.domain.bus.util.CityBusOpenApiClient; +import in.koreatech.koin.domain.coop.model.CoopEventListener; +import in.koreatech.koin.domain.owner.model.OwnerEventListener; +import in.koreatech.koin.domain.shop.model.ShopEventListener; +import in.koreatech.koin.domain.user.model.StudentEventListener; +import in.koreatech.koin.support.DBInitializer; +import io.restassured.RestAssured; +import jakarta.persistence.EntityManager; + +@SpringBootTest(webEnvironment = RANDOM_PORT) +@Import({DBInitializer.class, TestJpaConfiguration.class, TestTimeConfig.class}) +@ActiveProfiles("test") +public abstract class AcceptanceTest { + + private static final String ROOT = "test"; + private static final String ROOT_PASSWORD = "1234"; + + @LocalServerPort + protected int port; + + @SpyBean + protected CityBusOpenApiClient cityBusOpenApiClient; + + @MockBean + protected OwnerEventListener ownerEventListener; + + @MockBean + protected StudentEventListener studentEventListener; + + @MockBean + protected ShopEventListener shopEventListener; + + @MockBean + protected CoopEventListener coopEventListener; + + @Autowired + private DBInitializer dataInitializer; + + @Autowired + protected EntityManager entityManager; + + @Autowired + protected Clock clock; + + @Container + protected static MySQLContainer mySqlContainer; + + @Container + protected static GenericContainer redisContainer; + + @Container + protected static GenericContainer mongoContainer; + + @DynamicPropertySource + private static void configureProperties(final DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", mySqlContainer::getJdbcUrl); + registry.add("spring.datasource.username", () -> ROOT); + registry.add("spring.datasource.password", () -> ROOT_PASSWORD); + registry.add("spring.data.redis.host", redisContainer::getHost); + registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379).toString()); + registry.add("spring.data.mongodb.host", mongoContainer::getHost); + registry.add("spring.data.mongodb.port", () -> mongoContainer.getMappedPort(27017).toString()); + registry.add("spring.data.mongodb.database", () -> "test"); + } + + static { + mySqlContainer = (MySQLContainer)new MySQLContainer("mysql:8.0.29") + .withDatabaseName("test") + .withUsername(ROOT) + .withPassword(ROOT_PASSWORD) + .withCommand("--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"); + + redisContainer = new GenericContainer<>( + DockerImageName.parse("redis:7.0.9")) + .withExposedPorts(6379); + + mongoContainer = new GenericContainer<>( + DockerImageName.parse("mongo:6.0.14")) + .withExposedPorts(27017); + + mySqlContainer.start(); + redisContainer.start(); + mongoContainer.start(); + } + + @BeforeEach + void delete() { + if (RestAssured.port == RestAssured.UNDEFINED_PORT) { + RestAssured.port = port; + } + dataInitializer.clear(); + } +} diff --git a/src/test/java/in/koreatech/koin/KoinApplicationTest.java b/src/test/java/in/koreatech/koin/KoinApplicationTest.java index e49beb282..b9d1c1877 100644 --- a/src/test/java/in/koreatech/koin/KoinApplicationTest.java +++ b/src/test/java/in/koreatech/koin/KoinApplicationTest.java @@ -2,8 +2,15 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import in.koreatech.koin.config.TestJpaConfiguration; +import in.koreatech.koin.config.TestTimeConfig; @SpringBootTest +@ActiveProfiles("test") +@Import({TestJpaConfiguration.class, TestTimeConfig.class}) class KoinApplicationTest { @Test diff --git a/src/test/java/in/koreatech/koin/acceptance/ActivityApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ActivityApiTest.java new file mode 100644 index 000000000..1d372c434 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/ActivityApiTest.java @@ -0,0 +1,177 @@ +package in.koreatech.koin.acceptance; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.fixture.ActivityFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; + +@SuppressWarnings("NonAsciiCharacters") +class ActivityApiTest extends AcceptanceTest { + + @Autowired + protected ActivityFixture activityFixture; + + @Test + @DisplayName("BCSD Lab 활동 내역을 조회한다.") + void getActivities() { + activityFixture.builder() + .title("BCSD/KAP 통합") + .description("BCSD와 KAP가 통합되었습니다.") + .imageUrls("https://test.com.png") + .date(LocalDate.of(2018, 9, 12)) + .isDeleted(false) + .build(); + + activityFixture.builder() + .title("19-3기 모집") + .description("BCSD Lab과 함께 성장해나갈 인재를 모집했습니다.") + .imageUrls(""" + https://test2.com.png, + https://test3.com.png + """) + .date(LocalDate.of(2019, 7, 29)) + .isDeleted(false) + .build(); + + activityFixture.builder() + .title("코인 시간표 기능 추가") + .description("더 편리한 서비스 제공을 위해 시간표 기능을 추가했습니다") + .imageUrls("https://test4.com.png") + .date(LocalDate.of(2019, 8, 20)) + .isDeleted(false) + .build(); + + var response = RestAssured + .given() + .when() + .param("year", 2019) + .get("/activities") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "Activities": [ + { + "date": "2019-07-29", + "is_deleted": false, + "updated_at": "2024-01-15 12:00:00", + "created_at": "2024-01-15 12:00:00", + "description": "BCSD Lab과 함께 성장해나갈 인재를 모집했습니다.", + "image_urls": [ + "https://test2.com.png", + "https://test3.com.png" + ], + "id": 2, + "title": "19-3기 모집" + }, + { + "date": "2019-08-20", + "is_deleted": false, + "updated_at": "2024-01-15 12:00:00", + "created_at": "2024-01-15 12:00:00", + "description": "더 편리한 서비스 제공을 위해 시간표 기능을 추가했습니다", + "image_urls": [ + "https://test4.com.png" + ], + "id": 3, + "title": "코인 시간표 기능 추가" + } + ] + } + """); + } + + @Test + @DisplayName("BCSD Lab 활동 내역을 조회한다. - 파라미터가 없는 경우 전체조회") + void getActivitiesWithoutYear() { + activityFixture.builder() + .title("BCSD/KAP 통합") + .description("BCSD와 KAP가 통합되었습니다.") + .imageUrls("https://test.com.png") + .date(LocalDate.of(2018, 9, 12)) + .isDeleted(false) + .build(); + + activityFixture.builder() + .title("19-3기 모집") + .description("BCSD Lab과 함께 성장해나갈 인재를 모집했습니다.") + .imageUrls(""" + https://test2.com.png, + https://test3.com.png + """) + .date(LocalDate.of(2019, 7, 29)) + .isDeleted(false) + .build(); + + activityFixture.builder() + .title("코인 시간표 기능 추가") + .description("더 편리한 서비스 제공을 위해 시간표 기능을 추가했습니다") + .imageUrls("https://test4.com.png") + .date(LocalDate.of(2019, 8, 20)) + .isDeleted(false) + .build(); + + var response = RestAssured + .given() + .when() + .get("/activities") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "Activities": [ + { + "date": "2018-09-12", + "is_deleted": false, + "updated_at": "2024-01-15 12:00:00", + "created_at": "2024-01-15 12:00:00", + "description": "BCSD와 KAP가 통합되었습니다.", + "image_urls": [ + "https://test.com.png" + ], + "id": 1, + "title": "BCSD/KAP 통합" + }, + { + "date": "2019-07-29", + "is_deleted": false, + "updated_at": "2024-01-15 12:00:00", + "created_at": "2024-01-15 12:00:00", + "description": "BCSD Lab과 함께 성장해나갈 인재를 모집했습니다.", + "image_urls": [ + "https://test2.com.png", + "https://test3.com.png" + ], + "id": 2, + "title": "19-3기 모집" + }, + { + "date": "2019-08-20", + "is_deleted": false, + "updated_at": "2024-01-15 12:00:00", + "created_at": "2024-01-15 12:00:00", + "description": "더 편리한 서비스 제공을 위해 시간표 기능을 추가했습니다", + "image_urls": [ + "https://test4.com.png" + ], + "id": 3, + "title": "코인 시간표 기능 추가" + } + ] + } + """); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/AuthApiTest.java b/src/test/java/in/koreatech/koin/acceptance/AuthApiTest.java new file mode 100644 index 000000000..1446494e9 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/AuthApiTest.java @@ -0,0 +1,178 @@ +package in.koreatech.koin.acceptance; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.repository.UserTokenRepository; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +class AuthApiTest extends AcceptanceTest { + + @Autowired + private UserFixture userFixture; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserTokenRepository tokenRepository; + + @Test + @DisplayName("사용자가 로그인을 수행한다") + void userLoginSuccess() { + User user = userFixture.builder() + .password("1234") + .nickname("주노") + .name("최준호") + .phoneNumber("010-1234-5678") + .userType(STUDENT) + .email("test@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build(); + + var response = RestAssured + .given() + .body(""" + { + "email": "test@koreatech.ac.kr", + "password": "1234" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/user/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + User userResult = userRepository.findById(user.getId()).get(); + UserToken token = tokenRepository.findById(userResult.getId()).get(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(String.format(""" + { + "token": "%s", + "refresh_token": "%s", + "user_type": "%s" + } + """, + response.jsonPath().getString("token"), + token.getRefreshToken(), + user.getUserType().name() + )); + } + + @Test + @DisplayName("사용자가 로그인 이후 로그아웃을 수행한다") + void userLogoutSuccessg() { + User user = userFixture.builder() + .password("1234") + .nickname("주노") + .name("최준호") + .phoneNumber("010-1234-5678") + .userType(STUDENT) + .email("test@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build(); + + var response = RestAssured + .given() + .body(""" + { + "email": "test@koreatech.ac.kr", + "password": "1234" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/user/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + RestAssured + .given() + .header("Authorization", "Bearer " + response.jsonPath().getString("token")) + .when() + .post("/user/logout") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Assertions.assertThat(tokenRepository.findById(user.getId())).isEmpty(); + } + + @Test + @DisplayName("사용자가 로그인 이후 refreshToken을 재발급한다") + void userRefreshToken() { + User user = userFixture.builder() + .password("1234") + .nickname("주노") + .name("최준호") + .phoneNumber("010-1234-5678") + .userType(STUDENT) + .email("test@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build(); + + var loginResponse = RestAssured + .given() + .body(""" + { + "email": "test@koreatech.ac.kr", + "password": "1234" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/user/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + var response = RestAssured + .given() + .body(String.format(""" + { + "refresh_token": "%s" + } + """, + loginResponse.jsonPath().getString("refresh_token")) + ) + .contentType(ContentType.JSON) + .when() + .post("/user/refresh") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + UserToken token = tokenRepository.findById(user.getId()).get(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(String.format(""" + { + "token": "%s", + "refresh_token": "%s" + } + """, + response.jsonPath().getString("token"), + token.getRefreshToken() + )); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java new file mode 100644 index 000000000..a7040b857 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java @@ -0,0 +1,416 @@ +package in.koreatech.koin.acceptance; + +import static java.time.format.DateTimeFormatter.ofPattern; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.time.LocalTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; +import in.koreatech.koin.domain.bus.model.city.CityBusArrival; +import in.koreatech.koin.domain.bus.model.city.CityBusCache; +import in.koreatech.koin.domain.bus.model.city.CityBusCacheInfo; +import in.koreatech.koin.domain.bus.model.enums.BusDirection; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import in.koreatech.koin.domain.bus.model.enums.BusType; +import in.koreatech.koin.domain.bus.model.express.ExpressBusCache; +import in.koreatech.koin.domain.bus.model.express.ExpressBusCacheInfo; +import in.koreatech.koin.domain.bus.model.express.ExpressBusRoute; +import in.koreatech.koin.domain.bus.repository.CityBusCacheRepository; +import in.koreatech.koin.domain.bus.repository.ExpressBusCacheRepository; +import in.koreatech.koin.domain.version.model.Version; +import in.koreatech.koin.domain.version.model.VersionType; +import in.koreatech.koin.domain.version.repository.VersionRepository; +import in.koreatech.koin.fixture.BusFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; + +@SuppressWarnings("NonAsciiCharacters") +class BusApiTest extends AcceptanceTest { + + @Autowired + private BusFixture busFixture; + + @Autowired + private VersionRepository versionRepository; + + @Autowired + private CityBusCacheRepository cityBusCacheRepository; + + @Autowired + private ExpressBusCacheRepository expressBusCacheRepository; + + @BeforeEach + void setup() { + busFixture.버스_시간표_등록(); + when(cityBusOpenApiClient.getOpenApiResponse(any())).thenReturn(""" + { + "response": { + "header": { + "resultCode": "00", + "resultMsg": "NORMAL SERVICE." + }, + "body": { + "items": { + "item": [ + { + "arrprevstationcnt": 3, + "arrtime": 600, + "nodeid": "CAB285000686", + "nodenm": "종합터미널", + "routeid": "CAB285000003", + "routeno": 400, + "routetp": "일반버스", + "vehicletp": "저상버스" + }, + { + "arrprevstationcnt": 10, + "arrtime": 800, + "nodeid": "CAB285000686", + "nodenm": "종합터미널", + "routeid": "CAB285000024", + "routeno": 405, + "routetp": "일반버스", + "vehicletp": "일반차량" + }, + { + "arrprevstationcnt": 10, + "arrtime": 700, + "nodeid": "CAB285000686", + "nodenm": "종합터미널", + "routeid": "CAB285000024", + "routeno": 200, + "routetp": "일반버스", + "vehicletp": "일반차량" + } + ] + }, + "numOfRows": 30, + "pageNo": 1, + "totalCount": 3 + } + } + } + """); + } + + @Test + @DisplayName("다음 셔틀버스까지 남은 시간을 조회한다.") + void getNextShuttleBusRemainTime() { + var response = RestAssured + .given() + .when() + .param("bus_type", "shuttle") + .param("depart", "koreatech") + .param("arrival", "terminal") + .get("/bus") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "bus_type": "shuttle", + "now_bus": { + "bus_number": null, + "remain_time": 22200 + }, + "next_bus": null + } + """); + } + + @Test + @DisplayName("다음 시내버스까지 남은 시간을 조회한다. - Redis 캐시 히트") + void getNextCityBusRemainTimeRedis() { + final long remainTime = 600L; + final long busNumber = 400; + BusType busType = BusType.CITY; + BusStation depart = BusStation.TERMINAL; + BusStation arrival = BusStation.KOREATECH; + + BusDirection direction = BusStation.getDirection(depart, arrival); + Version version = versionRepository.save( + Version.builder() + .version("test_version") + .type(VersionType.CITY.getValue()) + .build() + ); + + cityBusCacheRepository.save( + CityBusCache.of( + depart.getNodeId(direction), + List.of(CityBusCacheInfo.of( + CityBusArrival.builder() + .routeno(busNumber) + .arrtime(remainTime) + .build(), + version.getUpdatedAt()) + ) + ) + ); + + var response = RestAssured + .given() + .when() + .param("bus_type", busType.getName()) + .param("depart", depart.name()) + .param("arrival", arrival.name()) + .get("/bus") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "bus_type": "city", + "now_bus": { + "bus_number": 400, + "remain_time": 600 + }, + "next_bus": null + } + """); + } + + @Test + @DisplayName("다음 시내버스까지 남은 시간을 조회한다. - OpenApi") + void getNextCityBusRemainTimeOpenApi() { + versionRepository.save( + Version.builder() + .version("test_version") + .type("city_bus_timetable") + .build() + ); + + var response = RestAssured + .given() + .when() + .param("bus_type", "city") + .param("depart", "terminal") + .param("arrival", "koreatech") + .get("/bus") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getString("bus_type")).isEqualTo("city"); + softly.assertThat((Long)response.body().jsonPath().getLong("now_bus.bus_number")).isEqualTo(400); + softly.assertThat((Long)response.body().jsonPath().getLong("next_bus.bus_number")).isEqualTo(405); + } + ); + } + + @Test + @DisplayName("셔틀버스의 코스 정보들을 조회한다.") + void getBusCourses() { + var response = RestAssured + .given() + .when() + .get("/bus/courses") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getList("").size()).isEqualTo(1); + softly.assertThat(response.body().jsonPath().getString("[0].bus_type")).isEqualTo("shuttle"); + softly.assertThat(response.body().jsonPath().getString("[0].direction")).isEqualTo("from"); + softly.assertThat(response.body().jsonPath().getString("[0].region")).isEqualTo("천안"); + } + ); + } + + @Test + @DisplayName("다음 셔틀버스까지 남은 시간을 조회한다.") + void getSearchTimetable() { + versionRepository.save( + Version.builder() + .version("test_version") + .type(VersionType.EXPRESS.getValue()) + .build() + ); + + ZonedDateTime requestedAt = ZonedDateTime.parse("2024-01-15 12:05:00 KST", + ofPattern("yyyy-MM-dd " + "HH:mm:ss z")); + + final String arrivalTime = "18:10"; + + BusStation depart = BusStation.from("koreatech"); + BusStation arrival = BusStation.from("terminal"); + + ExpressBusCache expressBusCache = ExpressBusCache.of( + new ExpressBusRoute(depart.getName(), arrival.getName()), + List.of( + new ExpressBusCacheInfo( + LocalTime.parse(arrivalTime), + LocalTime.parse("18:55"), + 5000 + ), + new ExpressBusCacheInfo( + LocalTime.parse(arrivalTime).plusSeconds(10), + LocalTime.parse("18:55"), + 5000 + ), + new ExpressBusCacheInfo( + LocalTime.parse(arrivalTime).plusSeconds(5), + LocalTime.parse("18:55"), + 5000 + ) + ) + ); + expressBusCacheRepository.save(expressBusCache); + + var response = RestAssured + .given() + .when() + .param("date", requestedAt.toLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) + .param("time", requestedAt.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm"))) + .param("depart", depart.name()) + .param("arrival", arrival.name()) + .get("/bus/search") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getList("", SingleBusTimeResponse.class)) + .containsExactly( + new SingleBusTimeResponse("express", LocalTime.parse(arrivalTime)), + new SingleBusTimeResponse("shuttle", LocalTime.parse(arrivalTime)), + new SingleBusTimeResponse("commuting", null) + ); + } + ); + } + + @Test + @DisplayName("시내버스 시간표를 조회한다 - 지원하지 않음") + void getCityBusTimetable() { + RestAssured + .given() + .when() + .param("bus_type", "city") + .param("direction", "to") + .param("region", "천안") + .get("/bus/timetable") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("셔틀버스 시간표를 조회한다.") + void getShuttleBusTimetable() { + var response = RestAssured + .given() + .when() + .param("bus_type", "shuttle") + .param("direction", "from") + .param("region", "천안") + .get("/bus/timetable") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "route_name": "주중", + "arrival_info": [ + { + "nodeName": "한기대", + "arrivalTime": "18:10" + }, + { + "nodeName": "신계초,운전리,연춘리", + "arrivalTime": "정차" + }, + { + "nodeName": "천안역(학화호두과자)", + "arrivalTime": "18:50" + }, + { + "nodeName": "터미널(신세계 앞 횡단보도)", + "arrivalTime": "18:55" + } + ] + } + ] + """); + } + + @Test + @DisplayName("셔틀버스 시간표를 조회한다(업데이트 시각 포함).") + void getShuttleBusTimetableWithUpdatedAt() { + Version version = Version.builder() + .version("test_version") + .type(VersionType.SHUTTLE.getValue()) + .build(); + versionRepository.save(version); + + BusType busType = BusType.from("shuttle"); + String direction = "from"; + String region = "천안"; + + var response = RestAssured + .given() + .when() + .param("bus_type", busType.getName()) + .param("direction", direction) + .param("region", region) + .get("/bus/timetable/v2") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "bus_timetable": [ + { + "route_name": "주중", + "arrival_info": [ + { + "nodeName": "한기대", + "arrivalTime": "18:10" + }, + { + "nodeName": "신계초,운전리,연춘리", + "arrivalTime": "정차" + }, + { + "nodeName": "천안역(학화호두과자)", + "arrivalTime": "18:50" + }, + { + "nodeName": "터미널(신세계 앞 횡단보도)", + "arrivalTime": "18:55" + } + ] + } + ], + "updated_at": "2024-01-15 12:00:00" + } + """); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/CommunityApiTest.java b/src/test/java/in/koreatech/koin/acceptance/CommunityApiTest.java new file mode 100644 index 000000000..c90527adc --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/CommunityApiTest.java @@ -0,0 +1,524 @@ +package in.koreatech.koin.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.community.model.Article; +import in.koreatech.koin.domain.community.model.Board; +import in.koreatech.koin.domain.community.model.Comment; +import in.koreatech.koin.domain.community.repository.ArticleRepository; +import in.koreatech.koin.domain.community.repository.CommentRepository; +import in.koreatech.koin.domain.user.model.Student; +import in.koreatech.koin.fixture.ArticleFixture; +import in.koreatech.koin.fixture.BoardFixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; + +@SuppressWarnings("NonAsciiCharacters") +class CommunityApiTest extends AcceptanceTest { + + @Autowired + private ArticleRepository articleRepository; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private UserFixture userFixture; + + @Autowired + private ArticleFixture articleFixture; + + @Autowired + private BoardFixture boardFixture; + + Student student; + Board board; + Article article1, article2; + + @BeforeEach + void givenBeforeEach() { + student = userFixture.준호_학생(); + board = boardFixture.자유게시판(); + article1 = articleFixture.자유글_1(student.getUser(), board); + article2 = articleFixture.자유글_2(student.getUser(), board); + } + + @Test + @DisplayName("특정 게시글을 단일 조회한다.") + void getArticle() { + // given + Comment request = Comment.builder() + .article(article1) + .content("댓글") + .userId(1) + .nickname("BCSD") + .isDeleted(false) + .build(); + commentRepository.save(request); + + // when then + var response = RestAssured + .given() + .when() + .get("/articles/{articleId}", article1.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "contentSummary": "내용", + "id": 1, + "board_id": 1, + "title": "자유 글의 제목입니다", + "content": "

내용

", + "nickname": "준호", + "is_solved": false, + "is_notice": false, + "hit": 1, + "comment_count": 0, + "board": { + "id": 1, + "tag": "FA001", + "name": "자유게시판", + "is_anonymous": false, + "article_count": 0, + "is_deleted": false, + "is_notice": false, + "parent_id": null, + "seq": 1, + "children": null, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + "comments": [ + { + "grantEdit": false, + "grantDelete": false, + "id": 1, + "article_id": 1, + "content": "댓글", + "user_id": 1, + "nickname": "BCSD", + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ], + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + """); + } + + @Test + @DisplayName("특정 게시글을 단일 조회한다. - 댓글 작성자가 본인이면 수정 및 제거 권한이 부여된다.") + void getArticleAuthorizationComment() { + // given + String token = userFixture.getToken(student.getUser()); + + Comment request = Comment.builder() + .article(article1) + .content("댓글") + .userId(1) + .nickname("BCSD") + .isDeleted(false) + .build(); + + Comment comment = commentRepository.save(request); + comment.updateAuthority(student.getUser().getId()); + + // when then + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/articles/{articleId}", article1.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "contentSummary": "내용", + "id": 1, + "board_id": 1, + "title": "자유 글의 제목입니다", + "content": "

내용

", + "nickname": "준호", + "is_solved": false, + "is_notice": false, + "hit": 2, + "comment_count": 0, + "board": { + "id": 1, + "tag": "FA001", + "name": "자유게시판", + "is_anonymous": false, + "article_count": 0, + "is_deleted": false, + "is_notice": false, + "parent_id": null, + "seq": 1, + "children": null, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + "comments": [ + { + "grantEdit": true, + "grantDelete": true, + "id": 1, + "article_id": 1, + "content": "댓글", + "user_id": 1, + "nickname": "BCSD", + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ], + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + """); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다.") + void getArticlesByPagination() { + // when then + var response = RestAssured + .given() + .when() + .param("boardId", board.getId()) + .param("page", 1) + .param("limit", 10) + .get("/articles") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "articles": [ + { + "contentSummary": "내용222", + "id": 2, + "board_id": 1, + "title": "자유 글2의 제목입니다", + "content": "

내용222

", + "user_id": 1, + "nickname": "준호", + "hit": 1, + "ip": "127.0.0.1", + "is_solved": false, + "is_deleted": false, + "comment_count": 0, + "meta": null, + "is_notice": false, + "notice_article_id": null, + "summary": null, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + { + "contentSummary": "내용", + "id": 1, + "board_id": 1, + "title": "자유 글의 제목입니다", + "content": "

내용

", + "user_id": 1, + "nickname": "준호", + "hit": 1, + "ip": "123.21.234.321", + "is_solved": false, + "is_deleted": false, + "comment_count": 0, + "meta": null, + "is_notice": false, + "notice_article_id": null, + "summary": null, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ], + "board": { + "id": 1, + "tag": "FA001", + "name": "자유게시판", + "is_anonymous": false, + "article_count": 0, + "is_deleted": false, + "is_notice": false, + "parent_id": null, + "seq": 1, + "children": null, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + "totalPage": 1 + } + """); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - 페이지가 0이면 1 페이지 조회") + void getArticlesByPagination_0Page() { + // when then + var response = RestAssured + .given() + .when() + .param("boardId", board.getId()) + .param("page", 0L) + .param("limit", 1) + .get("/articles") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(response.jsonPath().getInt("articles[0].id")).isEqualTo(article2.getId()); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - 페이지가 음수이면 1 페이지 조회") + void getArticlesByPagination_lessThan0Pages() { + // when then + var response = RestAssured + .given() + .when() + .param("boardId", board.getId()) + .param("page", -10L) + .param("limit", 1) + .get("/articles") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(response.jsonPath().getInt("articles[0].id")).isEqualTo(article2.getId()); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - limit가 0 이면 한 번에 1 게시글 조회") + void getArticlesByPagination_1imit() { + // when then + var response = RestAssured + .given() + .when() + .param("boardId", board.getId()) + .param("page", 1) + .param("limit", 0L) + .get("/articles") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(response.jsonPath().getList("articles")).hasSize(1); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - limit가 음수이면 한 번에 1 게시글 조회") + void getArticlesByPagination_lessThan0Limit() { + // when then + var response = RestAssured + .given() + .when() + .param("boardId", board.getId()) + .param("page", 1) + .param("limit", -10L) + .get("/articles") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(response.jsonPath().getList("articles")).hasSize(1); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - limit가 50 이상이면 한 번에 50 게시글 조회") + void getArticlesByPagination_over50Limit() { + // given + for (int i = 0; i < 60; i++) { + Article article = Article.builder() + .board(board) + .title("제목") + .content("

내용

") + .user(student.getUser()) + .nickname("BCSD") + .hit(14) + .ip("123.21.234.321") + .isSolved(false) + .isDeleted(false) + .commentCount((byte)2) + .meta(null) + .isNotice(false) + .noticeArticleId(null) + .build(); + articleRepository.save(article); + } + + // when then + var response = RestAssured + .given() + .when() + .param("boardId", board.getId()) + .param("page", 1) + .param("limit", 100L) + .get("/articles") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(response.jsonPath().getList("articles")).hasSize(50); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - 페이지, limit가 주어지지 않으면 1 페이지 10 게시글 조회") + void getArticlesByPagination_default() { + // given + for (int i = 0; i < 10; i++) { + Article article = Article.builder() + .board(board) + .title("제목") + .content("

내용

") + .user(student.getUser()) + .nickname("BCSD") + .hit(14) + .ip("123.21.234.321") + .isSolved(false) + .isDeleted(false) + .commentCount((byte)2) + .meta(null) + .isNotice(false) + .noticeArticleId(null) + .build(); + articleRepository.save(article); + } + + // when then + var response = RestAssured + .given() + .when() + .param("boardId", board.getId()) + .get("/articles") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(response.jsonPath().getList("articles")).hasSize(10); + + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - 요청된 페이지에 게시글이 존재하지 않으면 빈 게시글 배열을 반환한다.") + void getArticlesByPagination_overMaxPageNotFound() { + // when then + var response = RestAssured + .given() + .when() + .param("boardId", board.getId()) + .param("page", 10000L) + .param("limit", 1) + .get("/articles") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(response.jsonPath().getList("articles")).isEmpty(); + } + + @Test + @DisplayName("인기많은 게시글 목록을 조회한다.") + void getHotArticles() { + // given + for (int i = 5; i <= 7; i++) { + articleRepository.save(Article.builder() + .board(board) + .title(String.format("Article %d", i)) + .content("

내용

") + .user(student.getUser()) + .nickname("BCSD") + .hit(i) + .ip("123.21.234.321") + .isSolved(false) + .isDeleted(false) + .commentCount((byte)2) + .meta(null) + .isNotice(false) + .noticeArticleId(null) + .build() + ); + } + + // when then + var response = RestAssured + .given() + .when() + .get("/articles/hot/list") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "contentSummary": "내용", + "id": 5, + "board_id": 1, + "title": "Article 7", + "comment_count": 2, + "hit": 7, + "created_at": "2024-01-15 12:00:00" + }, + { + "contentSummary": "내용", + "id": 4, + "board_id": 1, + "title": "Article 6", + "comment_count": 2, + "hit": 6, + "created_at": "2024-01-15 12:00:00" + }, + { + "contentSummary": "내용", + "id": 3, + "board_id": 1, + "title": "Article 5", + "comment_count": 2, + "hit": 5, + "created_at": "2024-01-15 12:00:00" + }, + { + "contentSummary": "내용222", + "id": 2, + "board_id": 1, + "title": "자유 글2의 제목입니다", + "comment_count": 0, + "hit": 1, + "created_at": "2024-01-15 12:00:00" + }, + { + "contentSummary": "내용", + "id": 1, + "board_id": 1, + "title": "자유 글의 제목입니다", + "comment_count": 0, + "hit": 1, + "created_at": "2024-01-15 12:00:00" + } + ] + """); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/DeptApiTest.java b/src/test/java/in/koreatech/koin/acceptance/DeptApiTest.java new file mode 100644 index 000000000..55ff3c7e1 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/DeptApiTest.java @@ -0,0 +1,60 @@ +package in.koreatech.koin.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.dept.model.Dept; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; + +@SuppressWarnings("NonAsciiCharacters") +class DeptApiTest extends AcceptanceTest { + + @Test + @DisplayName("학과 번호를 통해 학과 이름을 조회한다.") + void findDeptNameByDeptNumber() { + // given + Dept dept = Dept.COMPUTER_SCIENCE; + + // when then + var response = RestAssured + .given() + .when() + .param("dept_num", dept.getNumbers().get(0)) + .get("/dept") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "dept_num": "35", + "name": "컴퓨터공학부" + } + """ + ); + } + + @Test + @DisplayName("모든 학과 정보를 조회한다.") + void findAllDepts() { + //given + final int DEPT_SIZE = Dept.values().length - 1; + + //when then + var response = RestAssured + .given() + .when() + .get("/depts") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(response.body().jsonPath().getList(".")).hasSize(DEPT_SIZE); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java new file mode 100644 index 000000000..c3e61ff2b --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java @@ -0,0 +1,297 @@ +package in.koreatech.koin.acceptance; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.coop.dto.DiningImageRequest; +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.repository.DiningRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.DiningFixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +class DiningApiTest extends AcceptanceTest { + + @Autowired + private DiningRepository diningRepository; + + @Autowired + private UserFixture userFixture; + + @Autowired + private DiningFixture diningFixture; + + @Test + @DisplayName("특정 날짜의 모든 식단들을 조회한다.") + void findDinings() { + diningFixture.능수관_점심(LocalDate.parse("2024-03-11")); + diningFixture.캠퍼스2_점심(LocalDate.parse("2024-03-11")); + + var response = given() + .when() + .get("/dinings?date=240311") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "id": 2, + "date": "2024-03-11", + "type": "LUNCH", + "place": "2캠퍼스", + "price_card": 6000, + "price_cash": 6000, + "kcal": 881, + "menu": [ + "혼합잡곡밥", + "가쓰오장국", + "땡초부추전", + "누룽지탕" + ], + "image_url": null, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00", + "soldout_at": null, + "changed_at": null + }, + { + "id": 1, + "date": "2024-03-11", + "type": "LUNCH", + "place": "능수관", + "price_card": 6000, + "price_cash": 6000, + "kcal": 300, + "menu": [ + "참치김치볶음밥", + "유부된장국", + "땡초부추전", + "누룽지탕" + ], + "image_url": null, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00", + "soldout_at": null, + "changed_at": null + } + ] + """); + } + + @Test + @DisplayName("잘못된 형식의 날짜로 조회한다. - 날짜의 형식이 잘못되었다면 400") + void invalidFormatDate() { + diningFixture.능수관_점심(LocalDate.parse("2024-03-11")); + + given() + .when() + .get("/dinings?date=20240311") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("날짜가 비어있다. - 오늘 날짜를 받아 조회한다.") + void nullDate() { + diningFixture.A코스_점심(LocalDate.parse("2024-01-15")); + diningFixture.캠퍼스2_점심(LocalDate.parse("2024-01-15")); + + var response = given() + .when() + .get("/dinings") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "id": 2, + "date": "2024-01-15", + "type": "LUNCH", + "place": "2캠퍼스", + "price_card": 6000, + "price_cash": 6000, + "kcal": 881, + "menu": [ + "혼합잡곡밥", + "가쓰오장국", + "땡초부추전", + "누룽지탕" + ], + "image_url": null, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00", + "soldout_at": null, + "changed_at": null + }, + { + "id": 1, + "date": "2024-01-15", + "type": "LUNCH", + "place": "A코스", + "price_card": 6000, + "price_cash": 6000, + "kcal": 881, + "menu": [ + "병아리콩밥", + "(탕)소고기육개장", + "땡초부추전", + "누룽지탕" + ], + "image_url": null, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00", + "soldout_at": null, + "changed_at": null + } + ] + """); + } + + @Test + @DisplayName("영양사 권한으로 품절 요청을 보내고 메뉴를 변경한다.") + void requestSoldOut() { + User user = userFixture.준기_영양사(); + String token = userFixture.getToken(user); + diningFixture.A코스_점심(LocalDate.parse("2024-03-11")); + Dining menu2 = diningFixture.B코스_점심(LocalDate.parse("2024-03-11")); + + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + token) + .body(String.format(""" + { + "menu_id": "%s", + "sold_out": %s + } + """, menu2.getId(), true) + ) + .when() + .patch("/coop/dining/soldout") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + } + + @Test + @DisplayName("권한이 없는 사용자가 품절 요청을 보낸다") + void requestSoldOutNoAuth() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Dining menu = diningFixture.A코스_점심(LocalDate.parse("2024-03-11")); + + var response = given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + token) + .body(String.format(""" + { + "menu_id": "%s", + "sold_out": %s + } + """, menu.getId(), true)) + .when() + .patch("/coop/dining/soldout") + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + .extract(); + } + + @Test + @DisplayName("영양사님 권한으로 식단 이미지를 업로드한다. - 이미지 URL이 DB에 저장된다.") + void ImageUpload() { + User user = userFixture.준기_영양사(); + String token = userFixture.getToken(user); + Dining menu = diningFixture.A코스_점심(LocalDate.parse("2024-03-11")); + + String imageUrl = "https://stage.koreatech.in/image.jpg"; + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + token) + .body(String.format(""" + { + "menu_id": "%s", + "image_url": "%s" + } + """, menu.getId(), imageUrl) + ) + .when() + .patch("/coop/dining/image") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Dining result = diningRepository.getById(menu.getId()); + assertThat(result.getImageUrl()).isEqualTo(imageUrl); + } + + @Test + @DisplayName("허용되지 않은 권한으로 식단 이미지를 업로드한다. - 권한 오류.") + void ImageUploadWithNoAuth() { + User user = userFixture.현수_사장님().getUser(); + String token = userFixture.getToken(user); + Dining menu = diningFixture.A코스_점심(LocalDate.parse("2024-03-11")); + + DiningImageRequest imageUrl = new DiningImageRequest(1, "https://stage.koreatech.in/image.jpg"); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + token) + .body(String.format(""" + { + "menu_id": "%s", + "image_url": "%s" + } + """, menu.getId(), imageUrl) + ) + .when() + .patch("/coop/dining/image") + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + .extract(); + } + + @Test + @DisplayName("품절 이벤트가 발생한다.") + void checkSoldOutEventListener() { + User user = userFixture.준기_영양사(); + String token = userFixture.getToken(user); + Dining menu = diningFixture.A코스_점심(LocalDate.parse("2024-03-11")); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + token) + .body(String.format(""" + { + "menu_id": "%s", + "sold_out": %s + } + """, menu.getId(), true)) + .when() + .patch("/coop/dining/soldout") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + verify(coopEventListener).onDiningSoldOutRequest(any()); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/LandApiTest.java b/src/test/java/in/koreatech/koin/acceptance/LandApiTest.java new file mode 100644 index 000000000..218a64aa3 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/LandApiTest.java @@ -0,0 +1,121 @@ +package in.koreatech.koin.acceptance; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.land.model.Land; +import in.koreatech.koin.fixture.LandFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; + +@SuppressWarnings("NonAsciiCharacters") +class LandApiTest extends AcceptanceTest { + + @Autowired + private LandFixture landFixture; + + @Test + @DisplayName("복덕방 리스트를 조회한다.") + void getLands() { + landFixture.신안빌(); + landFixture.에듀윌(); + + var response = RestAssured + .given() + .when() + .get("/lands") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "lands": [ + { + "internal_name": "신", + "monthly_fee": "100", + "latitude": 37.555, + "charter_fee": "1000", + "name": "신안빌", + "id": 1, + "longitude": 126.555, + "room_type": "원룸" + }, + { + "internal_name": "에", + "monthly_fee": "100", + "latitude": 37.555, + "charter_fee": "1000", + "name": "에듀윌", + "id": 2, + "longitude": 126.555, + "room_type": "원룸" + } + ] + } + """); + } + + @Test + @DisplayName("복덕방을 단일 조회한다.") + void getLand() { + Land land = landFixture.에듀윌(); + + var response = RestAssured + .given() + .when() + .get("/lands/{id}", land.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "opt_electronic_door_locks": false, + "opt_tv": false, + "monthly_fee": "100", + "opt_elevator": false, + "opt_water_purifier": false, + "opt_washer": false, + "latitude": 37.555, + "charter_fee": "1000", + "opt_veranda": false, + "created_at": "2024-01-15 12:00:00", + "description": null, + "image_urls": [ + "https://example1.test.com/image.jpeg", + "https://example2.test.com/image.jpeg" + ], + "opt_gas_range": false, + "opt_induction": false, + "internal_name": "에", + "is_deleted": false, + "updated_at": "2024-01-15 12:00:00", + "opt_bidet": false, + "opt_shoe_closet": false, + "opt_refrigerator": false, + "id": 1, + "floor": 1, + "management_fee": "100", + "opt_desk": false, + "opt_closet": false, + "longitude": 126.555, + "address": "천안시 동남구 강남구", + "opt_bed": false, + "size": "100.0", + "phone": "010-1133-5555", + "opt_air_conditioner": false, + "name": "에듀윌", + "deposit": "1000", + "opt_microwave": false, + "permalink": "%EC%97%90", + "room_type": "원룸" + } + """); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/MemberApiTest.java b/src/test/java/in/koreatech/koin/acceptance/MemberApiTest.java new file mode 100644 index 000000000..9e621a021 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/MemberApiTest.java @@ -0,0 +1,121 @@ +package in.koreatech.koin.acceptance; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.member.model.Member; +import in.koreatech.koin.domain.member.model.Track; +import in.koreatech.koin.domain.member.repository.TrackRepository; +import in.koreatech.koin.fixture.MemberFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; + +@SuppressWarnings("NonAsciiCharacters") +class MemberApiTest extends AcceptanceTest { + + @Autowired + private TrackRepository trackRepository; + + @Autowired + private MemberFixture memberFixture; + + Track backend; + Track frontend; + + @BeforeEach + void setUp() { + backend = trackRepository.save( + Track.builder() + .name("BackEnd") + .build() + ); + frontend = trackRepository.save( + Track.builder() + .name("FrontEnd") + .build() + ); + } + + @Test + @DisplayName("BCSDLab 회원의 정보를 조회한다") + void getMember() { + Member member = memberFixture.최준호(backend); + + var response = RestAssured + .given() + .when() + .get("/members/{id}", member.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(String.format(""" + { + "id": %d, + "name": "최준호", + "student_number": "2019136135", + "track": "BackEnd", + "position": "Regular", + "email": "testjuno@gmail.com", + "image_url": "https://imagetest.com/juno.jpg", + "is_deleted": false, + "created_at": "%s", + "updated_at": "%s" + }""", + member.getId(), + response.jsonPath().getString("created_at"), + response.jsonPath().getString("updated_at") + )); + } + + @Test + @DisplayName("BCSDLab 회원들의 정보를 조회한다") + void getMembers() { + memberFixture.최준호(backend); + memberFixture.박한수(frontend); + + var response = RestAssured + .given() + .when() + .get("/members") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "id": 1, + "name": "최준호", + "student_number": "2019136135", + "track": "BackEnd", + "position": "Regular", + "email": "testjuno@gmail.com", + "image_url": "https://imagetest.com/juno.jpg", + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + { + "id": 2, + "name": "박한수", + "student_number": "2019136064", + "track": "FrontEnd", + "position": "Regular", + "email": "testhsp@gmail.com", + "image_url": "https://imagetest.com/juno.jpg", + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ] + """ + ); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java b/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java new file mode 100644 index 000000000..8f57f958c --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java @@ -0,0 +1,240 @@ +package in.koreatech.koin.acceptance; + +import static in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType.SHOP_EVENT; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; +import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +class NotificationApiTest extends AcceptanceTest { + + @Autowired + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Autowired + private UserFixture userFixture; + + @Autowired + private UserRepository userRepository; + + User user; + String userToken; + + @BeforeEach + void setUp() { + user = userFixture.준호_학생().getUser(); + userToken = userFixture.getToken(user); + } + + @Test + @DisplayName("알림 구독 내역을 조회한다.") + void getNotificationSubscribe() { + //given + NotificationSubscribe notificationSubscribe = NotificationSubscribe.builder() + .subscribeType(SHOP_EVENT) + .user(user) + .build(); + + notificationSubscribeRepository.save(notificationSubscribe); + + //when then + var response = RestAssured + .given() + .header("Authorization", "Bearer " + userToken) + .when() + .get("/notification") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "is_permit": false, + "subscribes": [ + { + "type": "SHOP_EVENT", + "is_permit": true + }, + { + "type": "DINING_SOLD_OUT", + "is_permit": false + } + ] + } + """); + } + + @Test + @DisplayName("전체 알림을 구독한다. - 디바이스 토큰을 추가한다.") + void createDivceToken() { + //given + String deviceToken = "testToken"; + + //when then + RestAssured + .given() + .header("Authorization", "Bearer " + userToken) + .body(String.format(""" + { + "device_token": "%s" + } + """, deviceToken)) + .contentType(ContentType.JSON) + .when() + .post("/notification") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + User result = userRepository.getById(user.getId()); + assertThat(result.getDeviceToken()).isEqualTo(deviceToken); + } + + @Test + @DisplayName("특정 알림을 구독한다.") + void subscribeNotificationType() { + String deviceToken = "testToken"; + String notificationType = SHOP_EVENT.name(); + + RestAssured.given() + .header("Authorization", "Bearer " + userToken) + .body(String.format(""" + { + "device_token": "%s" + } + """, deviceToken)) + .contentType(ContentType.JSON) + .when() + .post("/notification") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + RestAssured + .given() + .header("Authorization", "Bearer " + userToken) + .contentType(ContentType.JSON) + .queryParam("type", notificationType) + .when() + .post("/notification/subscribe") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + userToken) + .when() + .get("/notification") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "is_permit": true, + "subscribes": [ + { + "type": "SHOP_EVENT", + "is_permit": true + }, + { + "type": "DINING_SOLD_OUT", + "is_permit": false + } + ] + } + """); + } + + @Test + @DisplayName("전체 알림 구독을 취소한다. - 디바이스 토큰을 삭제한다.") + void deleteDeviceToken() { + String deviceToken = "testToken"; + user.permitNotification(deviceToken); + + RestAssured + .given() + .header("Authorization", "Bearer " + userToken) + .when() + .delete("/notification") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + User result = userRepository.getById(user.getId()); + assertThat(result.getDeviceToken()).isNull(); + } + + @Test + @DisplayName("특정 알림 구독을 취소한다.") + void unsubscribeNotificationType() { + var SubscribeShopEvent = NotificationSubscribe.builder() + .subscribeType(SHOP_EVENT) + .user(user) + .build(); + + var SubscribeDiningSoldOut = NotificationSubscribe.builder() + .subscribeType(NotificationSubscribeType.DINING_SOLD_OUT) + .user(user) + .build(); + + notificationSubscribeRepository.save(SubscribeShopEvent); + notificationSubscribeRepository.save(SubscribeDiningSoldOut); + + String notificationType = SHOP_EVENT.name(); + + RestAssured + .given() + .header("Authorization", "Bearer " + userToken) + .queryParam("type", notificationType) + .when() + .delete("/notification/subscribe") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + userToken) + .when() + .get("/notification") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "is_permit": false, + "subscribes": [ + { + "type": "SHOP_EVENT", + "is_permit": false + }, + { + "type": "DINING_SOLD_OUT", + "is_permit": true + } + ] + } + """); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java new file mode 100644 index 000000000..d99c85382 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java @@ -0,0 +1,636 @@ +package in.koreatech.koin.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.verify; + +import org.assertj.core.api.Assertions; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.support.TransactionTemplate; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.owner.model.OwnerInVerification; +import in.koreatech.koin.domain.owner.repository.OwnerInVerificationRedisRepository; +import in.koreatech.koin.domain.owner.repository.OwnerRepository; +import in.koreatech.koin.domain.owner.repository.OwnerShopRedisRepository; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +class OwnerApiTest extends AcceptanceTest { + + @Autowired + private UserFixture userFixture; + + @Autowired + private ShopFixture shopFixture; + + @Autowired + private OwnerRepository ownerRepository; + + @Autowired + private OwnerShopRedisRepository ownerShopRedisRepository; + + @Autowired + private OwnerInVerificationRedisRepository ownerInVerificationRedisRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Test + @DisplayName("로그인된 사장님 정보를 조회한다.") + void getOwner() { + // given + Owner owner = userFixture.현수_사장님(); + Shop shop = shopFixture.마슬랜(owner); + String token = userFixture.getToken(owner.getUser()); + + // when then + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/owner") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(String.format(""" + { + "email": "hysoo@naver.com", + "name": "테스트용_현수", + "company_number": "123-45-67190", + "attachments": [ + { + "id": 1, + "file_url": "https://test.com/현수_사장님_인증사진_1.jpg", + "file_name": "현수_사장님_인증사진_1.jpg" + }, + { + "id": 2, + "file_url": "https://test.com/현수_사장님_인증사진_2.jpg", + "file_name": "현수_사장님_인증사진_2.jpg" + } + ], + "shops": [ + { + "id": %d, + "name": "마슬랜 치킨" + } + ] + } + """, shop.getId() + )); + } + + @Test + @DisplayName("사장님이 회원가입 인증번호 전송 요청을 한다 - 전송한 코드로 인증요청이 성공한다") + void requestAndVerifySign() { + String ownerEmail = "junho5336@gmail.com"; + RestAssured + .given() + .body(String.format(""" + { + "address": "%s" + } + """, ownerEmail) + ) + .contentType(ContentType.JSON) + .when() + .post("/owners/verification/email") + .then() + .statusCode(HttpStatus.OK.value()); + + var verifyCode = ownerInVerificationRedisRepository.getByVerify(ownerEmail); + + RestAssured + .given() + .body(String.format(""" + { + "address": "%s", + "certification_code": "%s" + } + """, ownerEmail, verifyCode.getCertificationCode())) + .contentType(ContentType.JSON) + .when() + .post("/owners/verification/code") + .then() + .statusCode(HttpStatus.OK.value()); + + var result = ownerInVerificationRedisRepository.findById(ownerEmail); + Assertions.assertThat(result).isNotPresent(); + } + + @Test + @DisplayName("사장님이 회원가입 인증번호 전송 요청을 한다 - 1분 이내로 재요청시 오류가 발생한다.") + void requestDuplicateVerifySign() { + String ownerEmail = "junho5336@gmail.com"; + RestAssured + .given() + .body(String.format(""" + { + "address": "%s" + } + """, ownerEmail)) + .contentType(ContentType.JSON) + .when() + .post("/owners/verification/email") + .then() + .statusCode(HttpStatus.OK.value()); + RestAssured + .given() + .body(String.format(""" + { + "address": "%s" + } + """, ownerEmail)) + .contentType(ContentType.JSON) + .when() + .post("/owners/verification/email") + .then() + .statusCode(HttpStatus.CONFLICT.value()); + } + + @Test + @DisplayName("사장님 회원가입 이메일 인증번호 전송 요청 이벤트 발생 시 슬랙 전송 이벤트가 발생한다.") + void checkOwnerEventListenerByEmail() { + RestAssured + .given() + .body(""" + { + "address": "test@gmail.com" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/owners/verification/email") + .then() + .statusCode(HttpStatus.OK.value()); + + verify(ownerEventListener).onOwnerEmailRequest(any()); + } + + @Nested + @DisplayName("사장님 회원가입") + class ownerRegister { + + @Test + @DisplayName("사장님이 회원가입 요청을 한다.") + void register() { + // when & then + var response = RestAssured + .given() + .body(""" + { + "attachment_urls": [ + { + "file_url": "https://static.koreatech.in/testimage.png" + } + ], + "company_number": "012-34-56789", + "email": "helloworld@koreatech.ac.kr", + "name": "최준호", + "password": "a0240120305812krlakdsflsa;1235", + "phone_number": "010-0000-0000", + "shop_id": null, + "shop_name": "기분좋은 뷔짱" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/owners/register") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + // when + transactionTemplate.executeWithoutResult(status -> { + Owner owner = ownerRepository.findByCompanyRegistrationNumber("012-34-56789").get(); + assertSoftly( + softly -> { + softly.assertThat(owner).isNotNull(); + softly.assertThat(owner.getUser().getName()).isEqualTo("최준호"); + softly.assertThat(owner.getUser().getEmail()).isEqualTo("helloworld@koreatech.ac.kr"); + softly.assertThat(owner.getCompanyRegistrationNumber()).isEqualTo("012-34-56789"); + softly.assertThat(owner.getAttachments().size()).isEqualTo(1); + softly.assertThat(owner.getAttachments().get(0).getUrl()) + .isEqualTo("https://static.koreatech.in/testimage.png"); + softly.assertThat(owner.getUser().isAuthed()).isFalse(); + softly.assertThat(owner.getUser().isDeleted()).isFalse(); + verify(ownerEventListener).onOwnerRegister(any()); + } + ); + } + ); + } + + @Test + @DisplayName("사장님이 회원가입 요청을 한다 - 첨부파일 이미지 URL이 잘못된 경우 400") + void registerNotAllowedFileUrl() { + // given + RestAssured + .given() + .body(""" + { + "attachment_urls": [ + { + "file_url": "https://hello.koreatech.in/testimage.png" + } + ], + "company_number": "012-34-56789", + "email": "helloworld@koreatech.ac.kr", + "name": "최준호", + "password": "a0240120305812krlakdsflsa;1235", + "phone_number": "010-0000-0000", + "shop_id": null, + "shop_name": "기분좋은 뷔짱" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/owners/register") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("사장님이 회원가입 요청을 한다 - 잘못된 사업자 등록번호인 경우 400") + void registerNotAllowedCompanyNumber() { + // given + RestAssured + .given() + .body(""" + { + "attachment_urls": [ + { + "file_url": "https://static.koreatech.in/testimage.png" + } + ], + "company_number": "8121-34-56789", + "email": "helloworld@koreatech.ac.kr", + "name": "최준호", + "password": "a0240120305812krlakdsflsa;1235", + "phone_number": "010-0000-0000", + "shop_id": null, + "shop_name": "기분좋은 뷔짱" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/owners/register") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("사장님이 회원가입 요청을 한다 - 이름이 없는경우 400") + void registerWithoutName() { + // given + RestAssured + .given() + .body(""" + { + "attachment_urls": [ + { + "file_url": "https://static.koreatech.in/testimage.png" + } + ], + "company_number": "011-34-56789", + "email": "helloworld@koreatech.ac.kr", + "name": "", + "password": "a0240120305812krlakdsflsa;1235", + "phone_number": "010-0000-0000", + "shop_id": null, + "shop_name": "기분좋은 뷔짱" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/owners/register") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("사장님이 회원가입 요청을 한다 - 기존에 존재하는 상점과 함께 회원가입") + void registerWithExistShop() { + // given + Shop shop = shopFixture.마슬랜(null); + RestAssured + .given() + .body(String.format(""" + { + "attachment_urls": [ + { + "file_url": "https://static.koreatech.in/testimage.png" + } + ], + "company_number": "011-34-12312", + "email": "helloworld@koreatech.ac.kr", + "name": "주노", + "password": "a0240120305812krlakdsflsa;1235", + "phone_number": "010-0000-0000", + "shop_id": %d, + "shop_name": "기분좋은 뷔짱" + } + """, shop.getId())) + .contentType(ContentType.JSON) + .when() + .post("/owners/register") + .then() + .statusCode(HttpStatus.OK.value()); + + Owner owner = ownerRepository.findByCompanyRegistrationNumber("011-34-12312").get(); + var ownerShop = ownerShopRedisRepository.findById(owner.getId()); + assertSoftly( + softly -> { + softly.assertThat(ownerShop).isNotNull(); + softly.assertThat(ownerShop.getShopId()).isEqualTo(shop.getId()); + } + ); + } + + @Test + @DisplayName("사장님이 회원가입 요청을 한다 - 존재하지 않는 상점과 함께 회원가입") + void registerWithNotExistShop() { + // given + RestAssured + .given() + .body(""" + { + "attachment_urls": [ + { + "file_url": "https://static.koreatech.in/testimage.png" + } + ], + "company_number": "011-34-56789", + "email": "helloworld@koreatech.ac.kr", + "name": "주노", + "password": "a0240120305812krlakdsflsa;1235", + "phone_number": "010-0000-0000", + "shop_id": null, + "shop_name": "기분좋은 뷔짱" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/owners/register") + .then() + .statusCode(HttpStatus.OK.value()); + Owner owner = ownerRepository.findByCompanyRegistrationNumber("011-34-56789").get(); + var ownerShop = ownerShopRedisRepository.findById(owner.getId()); + assertSoftly( + softly -> { + softly.assertThat(ownerShop).isNotNull(); + softly.assertThat(ownerShop.getShopId()).isNull(); + } + ); + } + } + + @Test + @DisplayName("사장님이 회원가입 인증번호를 확인한다") + void ownerCodeVerification() { + // given + OwnerInVerification verification = OwnerInVerification.of("junho5336@gmail.com", "123456"); + ownerInVerificationRedisRepository.save(verification); + RestAssured + .given() + .body(""" + { + "address": "junho5336@gmail.com", + "certification_code": "123456" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/owners/verification/code") + .then() + .statusCode(HttpStatus.OK.value()); + var result = ownerInVerificationRedisRepository.findById(verification.getKey()); + assertThat(result).isNotPresent(); + } + + @Test + @DisplayName("사장님이 회원가입 인증번호를 확인한다 - 존재하지 않는 이메일로 요청을 보낸다") + void ownerCodeVerificationNotExistEmail() { + // given + OwnerInVerification verification = OwnerInVerification.of("junho5336@gmail.com", "123456"); + ownerInVerificationRedisRepository.save(verification); + RestAssured + .given() + .body(""" + { + "address": "someone@gmail.com", + "certification_code": "123456" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/owners/verification/code") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()); + } + + @Test + @DisplayName("사장님이 비밀번호 변경을 위한 인증번호 이메일을 전송을 요청한다") + void sendResetPasswordEmail() { + // given + String email = "test@test.com"; + RestAssured + .given() + .body(String.format(""" + { + "address": "%s" + } + """, email) + ) + .contentType(ContentType.JSON) + .when() + .post("/owners/password/reset/verification") + .then() + .statusCode(HttpStatus.OK.value()); + + assertThat(ownerInVerificationRedisRepository.findById(email)).isPresent(); + } + + @Test + @DisplayName("사장님이 인증번호를 확인한다.") + void ownerVerify() { + // given + String email = "test@test.com"; + String code = "123123"; + OwnerInVerification verification = OwnerInVerification.of(email, code); + ownerInVerificationRedisRepository.save(verification); + RestAssured + .given() + .body(String.format(""" + { + "address": "%s", + "certification_code": "%s" + } + """, email, code) + ) + .contentType(ContentType.JSON) + .when() + .post("/owners/password/reset/send") + .then() + .statusCode(HttpStatus.OK.value()); + + var result = ownerInVerificationRedisRepository.getByVerify(email); + assertSoftly( + softly -> { + softly.assertThat(result).isNotNull(); + softly.assertThat(result.isAuthed()).isTrue(); + } + ); + } + + @Test + @DisplayName("사장님이 인증번호를 확인한다. - 중복 시 409를 반환한다.") + void ownerVerifyDuplicated() { + // given + String email = "test@test.com"; + String code = "123123"; + OwnerInVerification verification = OwnerInVerification.of(email, code); + ownerInVerificationRedisRepository.save(verification); + // when + RestAssured + .given() + .body(String.format(""" + { + "address": "%s", + "certification_code": "%s" + } + """, email, code) + ) + .contentType(ContentType.JSON) + .when() + .post("/owners/password/reset/send") + .then() + .statusCode(HttpStatus.OK.value()); + + // then + RestAssured + .given() + .body(String.format(""" + { + "address": "%s", + "certification_code": "%s" + } + """, email, code) + ) + .contentType(ContentType.JSON) + .when() + .post("/owners/password/reset/send") + .then() + .statusCode(HttpStatus.CONFLICT.value()); + } + + @Test + @DisplayName("사장님이 비밀번호를 변경한다.") + void ownerChangePassword() { + // given + User user = userFixture.현수_사장님().getUser(); + String code = "123123"; + OwnerInVerification verification = OwnerInVerification.of(user.getEmail(), code); + verification.verify(); + ownerInVerificationRedisRepository.save(verification); + String password = "asdf1234!"; + + // when + RestAssured + .given() + .body(String.format(""" + { + "address": "%s", + "password": "%s" + } + """, user.getEmail(), password) + ) + .contentType(ContentType.JSON) + .when() + .put("/owners/password/reset") + .then() + .statusCode(HttpStatus.OK.value()); + + // then + var result = ownerInVerificationRedisRepository.findById(user.getEmail()); + User userResult = userRepository.getByEmail(user.getEmail()); + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(result).isNotPresent(); + passwordEncoder.matches(password, userResult.getPassword()); + } + ); + } + + @Test + @DisplayName("사장님이 비밀번호를 변경한다. - 인증되지 않으면 400을 반환한다.") + void ownerChangePasswordNotAuthed() { + // given + String email = "test@test.com"; + String code = "123123"; + OwnerInVerification verification = OwnerInVerification.of(email, code); + ownerInVerificationRedisRepository.save(verification); + String password = "asdf1234!"; + + // when & then + RestAssured + .given() + .body(String.format(""" + { + "address": "%s", + "password": "%s" + } + """, email, password) + ) + .contentType(ContentType.JSON) + .when() + .put("/owners/password/reset") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("사장님이 회원탈퇴를 한다.") + void ownerDelete() { + // given + Owner owner = userFixture.현수_사장님(); + String token = userFixture.getToken(owner.getUser()); + + // when + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/user") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + // then + assertThat(userRepository.findById(owner.getId())).isNotPresent(); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java new file mode 100644 index 000000000..b3b6f925d --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java @@ -0,0 +1,947 @@ +package in.koreatech.koin.acceptance; + +import static java.time.format.DateTimeFormatter.ofPattern; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.transaction.support.TransactionTemplate; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.shop.model.EventArticle; +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.domain.shop.repository.EventArticleRepository; +import in.koreatech.koin.domain.shop.repository.MenuCategoryRepository; +import in.koreatech.koin.domain.shop.repository.MenuRepository; +import in.koreatech.koin.domain.shop.repository.ShopRepository; +import in.koreatech.koin.fixture.EventArticleFixture; +import in.koreatech.koin.fixture.MenuCategoryFixture; +import in.koreatech.koin.fixture.MenuFixture; +import in.koreatech.koin.fixture.ShopCategoryFixture; +import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +class OwnerShopApiTest extends AcceptanceTest { + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private MenuRepository menuRepository; + + @Autowired + private ShopRepository shopRepository; + + @Autowired + private MenuCategoryRepository menuCategoryRepository; + + @Autowired + private EventArticleRepository eventArticleRepository; + + @Autowired + private MenuFixture menuFixture; + + @Autowired + private UserFixture userFixture; + + @Autowired + private ShopFixture shopFixture; + + @Autowired + private ShopCategoryFixture shopCategoryFixture; + + @Autowired + private MenuCategoryFixture menuCategoryFixture; + + @Autowired + private EventArticleFixture eventArticleFixture; + + private Owner owner_현수; + private String token_현수; + private Owner owner_준영; + private String token_준영; + private Shop shop_마슬랜; + private ShopCategory shopCategory_치킨; + private ShopCategory shopCategory_일반; + private MenuCategory menuCategory_메인; + private MenuCategory menuCategory_사이드; + + @BeforeEach + void setUp() { + owner_현수 = userFixture.현수_사장님(); + token_현수 = userFixture.getToken(owner_현수.getUser()); + owner_준영 = userFixture.준영_사장님(); + token_준영 = userFixture.getToken(owner_준영.getUser()); + shop_마슬랜 = shopFixture.마슬랜(owner_현수); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(); + shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(); + menuCategory_메인 = menuCategoryFixture.메인메뉴(shop_마슬랜); + menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); + } + + @Test + @DisplayName("사장님의 가게 목록을 조회한다.") + void getOwnerShops() { + // given + shopFixture.신전_떡볶이(owner_현수); + + // when then + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .when() + .get("/owner/shops") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "count": 2, + "shops": [ + { + "id": 1, + "name": "마슬랜 치킨", + "is_event": false + }, + { + "id": 2, + "name": "신전 떡볶이", + "is_event": false + } + ] + } + """); + } + + @Test + @DisplayName("상점을 생성한다.") + void createOwnerShop() { + // given + RestAssured + .given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + token_현수) + .body(String.format(""" + { + "address": "대전광역시 유성구 대학로 291", + "category_ids": [ + %d + ], + "delivery": true, + "delivery_price": 4000, + "description": "테스트 상점2입니다.", + "image_urls": [ + "https://test.com/test1.jpg", + "https://test.com/test2.jpg", + "https://test.com/test3.jpg" + ], + "name": "테스트 상점2", + "open": [ + { + "close_time": [ + 21, + 0 + ], + "closed": false, + "day_of_week": "MONDAY", + "open_time": [ + 9, + 0 + ] + }, + { + "close_time": [ + 21, + 0 + ], + "closed": false, + "day_of_week": "WEDNESDAY", + "open_time": [ + 9, + 0 + ] + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-1234-5678" + } + """, shopCategory_치킨.getId()) + ) + .when() + .post("/owner/shops") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + List shops = shopRepository.findAllByOwnerId(owner_현수.getId()); + Shop result = shops.get(1); + assertSoftly( + softly -> { + softly.assertThat(result.getAddress()).isEqualTo("대전광역시 유성구 대학로 291"); + softly.assertThat(result.getDeliveryPrice()).isEqualTo(4000); + softly.assertThat(result.getDescription()).isEqualTo("테스트 상점2입니다."); + softly.assertThat(result.getName()).isEqualTo("테스트 상점2"); + softly.assertThat(result.getShopImages()).hasSize(3); + softly.assertThat(result.getShopOpens()).hasSize(2); + softly.assertThat(result.getShopCategories()).hasSize(1); + } + ); + }); + } + + @Test + @DisplayName("상점 사장님이 특정 상점 조회") + void getShop() { + // given + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .when() + .get("/owner/shops/{shopId}", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "address": "천안시 동남구 병천면 1600", + "delivery": true, + "delivery_price": 3000, + "description": "마슬랜 치킨입니다.", + "id": 1, + "image_urls": [ + "https://test-image.com/마슬랜.png", + "https://test-image.com/마슬랜2.png" + ], + "menu_categories": [ + { + "id": 1, + "name": "메인메뉴" + }, + { + "id": 2, + "name": "사이드메뉴" + } + ], + "name": "마슬랜 치킨", + "open": [ + { + "day_of_week": "MONDAY", + "closed": false, + "open_time": "00:00", + "close_time": "21:00" + }, + { + "day_of_week": "FRIDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "shop_categories": [ + + ], + "updated_at": "2024-01-15", + "is_event": false + } + """); + } + + @Test + @DisplayName("특정 상점의 모든 메뉴를 조회한다.") + void findOwnerShopMenu() { + // given + menuFixture.짜장면_옵션메뉴(shop_마슬랜, menuCategory_메인); + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .param("shopId", shop_마슬랜.getId()) + .when() + .get("/owner/shops/menus") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "count": 1, + "menu_categories": [ + { + "id": 1, + "name": "메인메뉴", + "menus": [ + { + "id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": false, + "single_price": null, + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "description": "맛있는 짜장면", + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } + ] + } + ], + "updated_at": "2024-01-15" + } + """); + } + + @Test + @DisplayName("사장님이 자신의 상점 메뉴 카테고리들을 조회한다.") + void findOwnerMenuCategories() { + // given + menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + + var response = RestAssured + .given() + .param("shopId", shop_마슬랜.getId()) + .header("Authorization", "Bearer " + token_현수) + .when() + .get("/owner/shops/menus/categories") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "count": 2, + "menu_categories": [ + { + "id": 1, + "name": "메인메뉴" + }, + { + "id": 2, + "name": "사이드메뉴" + } + ] + } + """); + } + + @Test + @DisplayName("사장님이 자신의 상점의 특정 메뉴를 조회한다.") + void findMenuShopOwner() { + // given + Menu menu = menuFixture.짜장면_옵션메뉴(shop_마슬랜, menuCategory_메인); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .when() + .get("/owner/shops/menus/{menuId}", menu.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getInt("id")).isEqualTo(menu.getId()); + + softly.assertThat(response.body().jsonPath().getInt("shop_id")).isEqualTo(menu.getShopId()); + softly.assertThat(response.body().jsonPath().getString("name")).isEqualTo(menu.getName()); + softly.assertThat(response.body().jsonPath().getBoolean("is_hidden")).isEqualTo(menu.isHidden()); + + softly.assertThat(response.body().jsonPath().getBoolean("is_single")).isFalse(); + softly.assertThat((Integer)response.body().jsonPath().get("single_price")).isNull(); + softly.assertThat(response.body().jsonPath().getList("option_prices")).hasSize(2); + softly.assertThat(response.body().jsonPath().getString("description")).isEqualTo(menu.getDescription()); + softly.assertThat(response.body().jsonPath().getList("category_ids")) + .hasSize(menu.getMenuCategoryMaps().size()); + softly.assertThat(response.body().jsonPath().getList("image_urls")) + .hasSize(menu.getMenuImages().size()); + } + ); + } + + @Test + @DisplayName("권한이 없는 상점 사장님이 특정 상점 조회") + void ownerCannotQueryOtherStoresWithoutPermission() { + // given + RestAssured + .given() + .header("Authorization", "Bearer " + token_준영) + .when() + .get("/owner/shops/{shopId}", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + .extract(); + } + + @Test + @DisplayName("사장님이 메뉴 카테고리를 삭제한다.") + void deleteMenuCategory() { + // when & then + RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .when() + .delete("/owner/shops/menus/categories/{categoryId}", menuCategory_메인.getId()) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + assertThat(menuCategoryRepository.findById(menuCategory_메인.getId())).isNotPresent(); + } + + @Test + @DisplayName("사장님이 메뉴를 삭제한다.") + void deleteMenu() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .when() + .delete("/owner/shops/menus/{menuId}", menu.getId()) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + assertThat(menuRepository.findById(menu.getId())).isNotPresent(); + } + + @Test + @DisplayName("사장님이 옵션이 여러개인 메뉴를 추가한다.") + void createManyOptionMenu() { + // given + MenuCategory menuCategory = menuCategory_메인; + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %s + ], + "description": "테스트메뉴입니다.", + "image_urls": [ + "https://test-image.com/짜장면.jpg" + ], + "is_single": false, + "name": "짜장면", + "option_prices": [ + { + "option": "중", + "price": 10000 + }, + { + "option": "소", + "price": 5000 + } + ] + } + """, menuCategory.getId())) + .when() + .post("/owner/shops/{id}/menus", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu menu = menuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = menu.getMenuCategoryMaps(); + List menuOptions = menu.getMenuOptions(); + List menuImages = menu.getMenuImages(); + softly.assertThat(menu.getDescription()).isEqualTo("테스트메뉴입니다."); + softly.assertThat(menu.getName()).isEqualTo("짜장면"); + softly.assertThat(menuImages.get(0).getImageUrl()).isEqualTo("https://test-image.com/짜장면.jpg"); + softly.assertThat(menuCategoryMaps.get(0).getMenuCategory().getId()).isEqualTo(1); + softly.assertThat(menuOptions).hasSize(2); + } + ); + }); + } + + @Test + @DisplayName("사장님이 옵션이 한개인 메뉴를 추가한다.") + void createOneOptionMenu() { + // given + MenuCategory menuCategory = menuCategory_메인; + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %s + ], + "description": "테스트메뉴입니다.", + "image_urls": [ + "https://test-image.com/짜장면.jpg" + ], + "is_single": true, + "name": "짜장면", + "option_prices": null, + "single_price": 10000 + } + """, menuCategory.getId())) + .when() + .post("/owner/shops/{id}/menus", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu menu = menuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = menu.getMenuCategoryMaps(); + List menuOptions = menu.getMenuOptions(); + List menuImages = menu.getMenuImages(); + softly.assertThat(menu.getDescription()).isEqualTo("테스트메뉴입니다."); + softly.assertThat(menu.getName()).isEqualTo("짜장면"); + + softly.assertThat(menuImages.get(0).getImageUrl()).isEqualTo("https://test-image.com/짜장면.jpg"); + softly.assertThat(menuCategoryMaps.get(0).getMenuCategory().getId()).isEqualTo(1); + + softly.assertThat(menuOptions.get(0).getPrice()).isEqualTo(10000); + } + ); + }); + } + + @Test + @DisplayName("사장님이 메뉴 카테고리를 추가한다.") + void createMenuCategory() { + // given + RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "name": "대박메뉴" + } + """)) + .when() + .post("/owner/shops/{id}/menus/categories", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + var menuCategories = menuCategoryRepository.findAllByShopId(shop_마슬랜.getId()); + + assertThat(menuCategories).anyMatch(menuCategory -> "대박메뉴".equals(menuCategory.getName())); + } + + @Test + @DisplayName("사장님이 단일 메뉴로 수정한다.") + void modifyOneMenu() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %d + ], + "description": "테스트메뉴수정", + "image_urls": [ + "https://test-image.net/테스트메뉴.jpeg" + ], + "is_single": true, + "name": "짜장면2", + "single_price": 10000 + } + """, shopCategory_일반.getId())) + .when() + .put("/owner/shops/menus/{menuId}", menu.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu result = menuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = result.getMenuCategoryMaps(); + List menuOptions = result.getMenuOptions(); + List menuImages = result.getMenuImages(); + softly.assertThat(result.getDescription()).isEqualTo("테스트메뉴수정"); + softly.assertThat(result.getName()).isEqualTo("짜장면2"); + + softly.assertThat(menuImages.get(0).getImageUrl()).isEqualTo("https://test-image.net/테스트메뉴.jpeg"); + softly.assertThat(menuCategoryMaps.get(0).getMenuCategory().getId()).isEqualTo(2); + + softly.assertThat(menuOptions.get(0).getPrice()).isEqualTo(10000); + + } + ); + }); + } + + @Test + @DisplayName("사장님이 여러옵션을 가진 메뉴로 수정한다.") + void modifyManyOptionMenu() { + // given + Menu menu = menuFixture.짜장면_옵션메뉴(shop_마슬랜, menuCategory_메인); + RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %d, %d + ], + "description": "테스트메뉴입니다.", + "image_urls": [ + "https://fixed-testimage.com/수정된짜장면.png" + ], + "is_single": false, + "name": "짜장면", + "option_prices": [ + { + "option": "중", + "price": 10000 + }, + { + "option": "소", + "price": 5000 + } + ] + } + """, menuCategory_메인.getId(), menuCategory_사이드.getId()) + ) + .when() + .put("/owner/shops/menus/{menuId}", menu.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu result = menuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = result.getMenuCategoryMaps(); + List menuOptions = result.getMenuOptions(); + List menuImages = result.getMenuImages(); + softly.assertThat(result.getDescription()).isEqualTo("테스트메뉴입니다."); + softly.assertThat(result.getName()).isEqualTo("짜장면"); + softly.assertThat(menuImages.get(0).getImageUrl()) + .isEqualTo("https://fixed-testimage.com/수정된짜장면.png"); + softly.assertThat(menuCategoryMaps).hasSize(2); + softly.assertThat(menuOptions).hasSize(2); + } + ); + }); + } + + @Test + @DisplayName("사장님이 상점을 수정한다.") + void modifyShop() { + // given + RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "address": "충청남도 천안시 동남구 병천면 충절로 1600", + "category_ids": [ + %d, %d + ], + "delivery": false, + "delivery_price": 1000, + "description": "이번주 전 메뉴 10%% 할인 이벤트합니다.", + "image_urls": [ + "https://fixed-shopimage.com/수정된_상점_이미지.png" + ], + "name": "써니 숯불 도시락", + "open": [ + { + "close_time": "22:30", + "closed": false, + "day_of_week": "MONDAY", + "open_time": "10:00" + }, + { + "close_time": "23:30", + "closed": true, + "day_of_week": "SUNDAY", + "open_time": "11:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "041-123-4567" + } + """, shopCategory_일반.getId(), shopCategory_치킨.getId() + )) + .when() + .put("/owner/shops/{id}", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Shop result = shopRepository.getById(1); + List shopImages = result.getShopImages(); + List shopOpens = result.getShopOpens(); + List shopCategoryMaps = result.getShopCategories(); + assertSoftly( + softly -> { + softly.assertThat(result.getAddress()).isEqualTo("충청남도 천안시 동남구 병천면 충절로 1600"); + softly.assertThat(result.isDeleted()).isFalse(); + softly.assertThat(result.getDeliveryPrice()).isEqualTo(1000); + softly.assertThat(result.getDescription()).isEqualTo("이번주 전 메뉴 10% 할인 이벤트합니다."); + softly.assertThat(result.getName()).isEqualTo("써니 숯불 도시락"); + softly.assertThat(result.isPayBank()).isTrue(); + softly.assertThat(result.isPayCard()).isTrue(); + softly.assertThat(result.getPhone()).isEqualTo("041-123-4567"); + + softly.assertThat(shopCategoryMaps.get(0).getShopCategory().getId()).isEqualTo(1); + softly.assertThat(shopCategoryMaps.get(1).getShopCategory().getId()).isEqualTo(2); + + softly.assertThat(shopImages.get(0).getImageUrl()) + .isEqualTo("https://fixed-shopimage.com/수정된_상점_이미지.png"); + + softly.assertThat(shopOpens.get(0).getCloseTime()).isEqualTo("22:30"); + softly.assertThat(shopOpens.get(0).getOpenTime()).isEqualTo("10:00"); + + softly.assertThat(shopOpens.get(0).getDayOfWeek()).isEqualTo("MONDAY"); + softly.assertThat(shopOpens.get(0).isClosed()).isFalse(); + + softly.assertThat(shopOpens.get(1).getCloseTime()).isEqualTo("23:30"); + softly.assertThat(shopOpens.get(1).getOpenTime()).isEqualTo("11:00"); + + softly.assertThat(shopOpens.get(1).getDayOfWeek()).isEqualTo("SUNDAY"); + softly.assertThat(shopOpens.get(1).isClosed()).isTrue(); + } + ); + }); + } + + @Test + @DisplayName("권한이 없는 상점 사장님이 특정 카테고리 조회한다.") + void ownerCannotQueryOtherCategoriesWithoutPermission() { + // given + RestAssured + .given() + .param("shopId", 1) + .header("Authorization", "Bearer " + token_준영) + .when() + .get("/owner/shops/menus/categories") + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + .extract(); + } + + @Test + @DisplayName("권한이 없는 상점 사장님이 특정 메뉴 조회한다.") + void ownerCannotQueryOtherMenusWithoutPermission() { + // given + RestAssured + .given() + .header("Authorization", "Bearer " + token_준영) + .param("shopId", 1) + .when() + .get("/owner/shops/menus") + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + .extract(); + } + + @Test + @DisplayName("권한이 없는 사장님이 메뉴 카테고리를 삭제한다.") + void ownerCannotDeleteOtherCategoriesWithoutPermission() { + // given + MenuCategory menuCategory = menuCategoryFixture.세트메뉴(shop_마슬랜); + RestAssured + .given() + .header("Authorization", "Bearer " + token_준영) + .when() + .delete("/owner/shops/menus/categories/{categoryId}", menuCategory.getId()) + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + .extract(); + } + + @Test + @DisplayName("권한이 없는 사장님이 메뉴를 삭제한다.") + void ownerCannotDeleteOtherMenusWithoutPermission() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_준영) + .when() + .delete("/owner/shops/menus/{menuId}", menu.getId()) + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + .extract(); + } + + @Test + @DisplayName("사장님이 이벤트를 추가한다.") + void ownerShopCreateEvent() { + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(10); + RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "title": "감성떡볶이 이벤트합니다!", + "content": "테스트 이벤트입니다.", + "thumbnail_images": [ + "https://test.com/test1.jpg" + ], + "start_date": "%s", + "end_date": "%s" + } + """, + startDate.format(ofPattern("yyyy-MM-dd")), + endDate.format(ofPattern("yyyy-MM-dd")) + )) + .when() + .post("/owner/shops/{shopId}/event", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + EventArticle eventArticle = eventArticleRepository.getById(1); + assertSoftly( + softly -> { + softly.assertThat(eventArticle.getShop().getId()).isEqualTo(1); + softly.assertThat(eventArticle.getTitle()).isEqualTo("감성떡볶이 이벤트합니다!"); + softly.assertThat(eventArticle.getContent()).isEqualTo("테스트 이벤트입니다."); + softly.assertThat(eventArticle.getThumbnailImages().get(0).getThumbnailImage()) + .isEqualTo("https://test.com/test1.jpg"); + softly.assertThat(eventArticle.getStartDate()).isEqualTo(startDate); + softly.assertThat(eventArticle.getEndDate()).isEqualTo(endDate); + } + ); + }); + verify(shopEventListener).onShopEventCreate(any()); + } + + @Test + @DisplayName("사장님이 이벤트를 수정한다.") + void ownerShopModifyEvent() { + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(10); + EventArticle eventArticle = eventArticleFixture.할인_이벤트(shop_마슬랜, startDate, endDate); + RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "title": "감성떡볶이 이벤트합니다!", + "content": "테스트 이벤트입니다.", + "thumbnail_images": [ + "https://test.com/test1.jpg" + ], + "start_date": "%s", + "end_date": "%s" + } + """, + startDate, + endDate)) + .when() + .put("/owner/shops/{shopId}/events/{eventId}", shop_마슬랜.getId(), eventArticle.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + EventArticle result = eventArticleRepository.getById(eventArticle.getId()); + assertSoftly( + softly -> { + softly.assertThat(result.getShop().getId()).isEqualTo(1); + softly.assertThat(result.getTitle()).isEqualTo("감성떡볶이 이벤트합니다!"); + softly.assertThat(result.getContent()).isEqualTo("테스트 이벤트입니다."); + softly.assertThat(result.getThumbnailImages().get(0).getThumbnailImage()) + .isEqualTo("https://test.com/test1.jpg"); + softly.assertThat(result.getStartDate()).isEqualTo(startDate); + softly.assertThat(result.getEndDate()).isEqualTo(endDate); + } + ); + }); + } + + @Test + @DisplayName("사장님이 이벤트를 삭제한다.") + void ownerShopDeleteEvent() { + EventArticle eventArticle = eventArticleFixture.할인_이벤트( + shop_마슬랜, + LocalDate.of(2024, 10, 24), + LocalDate.of(2024, 10, 26) + ); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_현수) + .contentType(ContentType.JSON) + .when() + .delete("/owner/shops/{shopId}/events/{eventId}", shop_마슬랜.getId(), eventArticle.getId()) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + Optional modifiedEventArticle = eventArticleRepository.findById(eventArticle.getId()); + assertThat(modifiedEventArticle).isNotPresent(); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java new file mode 100644 index 000000000..f6dffa541 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -0,0 +1,615 @@ +package in.koreatech.koin.acceptance; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.fixture.EventArticleFixture; +import in.koreatech.koin.fixture.MenuCategoryFixture; +import in.koreatech.koin.fixture.MenuFixture; +import in.koreatech.koin.fixture.ShopCategoryFixture; +import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +class ShopApiTest extends AcceptanceTest { + + @Autowired + private UserFixture userFixture; + + @Autowired + private ShopFixture shopFixture; + + @Autowired + private MenuFixture menuFixture; + + @Autowired + private MenuCategoryFixture menuCategoryFixture; + + @Autowired + private EventArticleFixture eventArticleFixture; + + @Autowired + private ShopCategoryFixture shopCategoryFixture; + + private Shop shop; + private Owner owner; + + @BeforeEach + void setUp() { + owner = userFixture.준영_사장님(); + shop = shopFixture.마슬랜(owner); + } + + @Test + @DisplayName("옵션이 하나 있는 상점의 메뉴를 조회한다.") + void findMenuSingleOption() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop, menuCategoryFixture.메인메뉴(shop)); + + var response = RestAssured + .given() + .when() + .get("/shops/{shopId}/menus/{menuId}", menu.getShopId(), menu.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "shop_id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": true, + "single_price": 7000, + "option_prices": null, + "description": "맛있는 짜장면", + "category_ids": [ + 1 + ], + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } + """ + ); + } + + @Test + @DisplayName("옵션이 여러 개 있는 상점의 메뉴를 조회한다.") + void findMenuMultipleOption() { + // given + Menu menu = menuFixture.짜장면_옵션메뉴(shop, menuCategoryFixture.메인메뉴(shop)); + + var response = RestAssured + .given() + .when() + .get("/shops/{shopId}/menus/{menuId}", menu.getShopId(), menu.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "shop_id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": false, + "single_price": null, + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "description": "맛있는 짜장면", + "category_ids": [ + 1 + ], + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } + HTTP/1.1 200\s + Vary: Origin + Vary: Access-Control-Request-Method + Vary: Access-Control-Request-Headers + Content-Type: application/json + Transfer-Encoding: chunked + Date: Mon, 22 Apr 2024 15:59:58 GMT + Keep-Alive: timeout=60 + Connection: keep-alive + + { + "id": 1, + "shop_id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": false, + "single_price": null, + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "description": "맛있는 짜장면", + "category_ids": [ + 1 + ], + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } + """); + } + + @Test + @DisplayName("상점의 메뉴 카테고리들을 조회한다.") + void findShopMenuCategories() { + // given + menuCategoryFixture.사이드메뉴(shop); + menuCategoryFixture.세트메뉴(shop); + Menu menu = menuFixture.짜장면_단일메뉴(shop, menuCategoryFixture.추천메뉴(shop)); + + var response = RestAssured + .given() + .when() + .get("/shops/{shopId}/menus/categories", menu.getShopId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "count": 3, + "menu_categories": [ + { + "id": 1, + "name": "사이드메뉴" + }, + { + "id": 2, + "name": "세트메뉴" + }, + { + "id": 3, + "name": "추천메뉴" + } + ] + } + """); + } + + @Test + @DisplayName("특정 상점 조회") + void getShop() { + // given + menuCategoryFixture.사이드메뉴(shop); + menuCategoryFixture.세트메뉴(shop); + var response = RestAssured + .given() + .when() + .get("/shops/{shopId}", shop.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "address": "천안시 동남구 병천면 1600", + "delivery": true, + "delivery_price": 3000, + "description": "마슬랜 치킨입니다.", + "id": 1, + "image_urls": [ + "https://test-image.com/마슬랜.png", + "https://test-image.com/마슬랜2.png" + ], + "menu_categories": [ + { + "id": 1, + "name": "사이드메뉴" + }, + { + "id": 2, + "name": "세트메뉴" + } + ], + "name": "마슬랜 치킨", + "open": [ + { + "day_of_week": "MONDAY", + "closed": false, + "open_time": "00:00", + "close_time": "21:00" + }, + { + "day_of_week": "FRIDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "shop_categories": [ + + ], + "updated_at": "2024-01-15", + "is_event": false + } + """ + ); + } + + @Test + @DisplayName("특정 상점 모든 메뉴 조회") + void getShopMenus() { + menuFixture.짜장면_단일메뉴(shop, menuCategoryFixture.추천메뉴(shop)); + menuFixture.짜장면_옵션메뉴(shop, menuCategoryFixture.세트메뉴(shop)); + var response = RestAssured + .given() + .when() + .get("/shops/{shopId}/menus", shop.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "count": 2, + "menu_categories": [ + { + "id": 1, + "name": "추천메뉴", + "menus": [ + { + "id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": true, + "single_price": 7000, + "option_prices": null, + "description": "맛있는 짜장면", + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } + ] + }, + { + "id": 2, + "name": "세트메뉴", + "menus": [ + { + "id": 2, + "name": "짜장면", + "is_hidden": false, + "is_single": false, + "single_price": null, + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "description": "맛있는 짜장면", + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } + ] + } + ], + "updated_at": "2024-01-15" + } + """ + ); + } + + @Test + @DisplayName("모든 상점 조회") + void getAllShop() { + // given + shopFixture.신전_떡볶이(owner); + var response = RestAssured + .given() + .when() + .get("/shops") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + // 2024-01-15 월요일 기준 + boolean 마슬랜_영업여부 = true; + boolean 신전_떡볶이_영업여부 = false; + + System.out.println(LocalDateTime.now(clock)); + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(String.format(""" + { + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "open": [ + { + "day_of_week": "MONDAY", + "closed": false, + "open_time": "00:00", + "close_time": "21:00" + }, + { + "day_of_week": "FRIDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "신전 떡볶이", + "open": [ + { + "day_of_week": "SUNDAY", + "closed": false, + "open_time": "00:00", + "close_time": "21:00" + }, + { + "day_of_week": "FRIDAY", + "closed": false, + "open_time": "00:00", + "close_time": "21:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s + } + ] + } + """, 마슬랜_영업여부, 신전_떡볶이_영업여부)); + } + + @Test + @DisplayName("상점들의 모든 카테고리를 조회한다.") + void getAllShopCategories() { + // given + shopCategoryFixture.카테고리_일반음식(); + shopCategoryFixture.카테고리_치킨(); + var response = RestAssured + .given() + .when() + .get("/shops/categories") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "total_count": 2, + "shop_categories": [ + { + "id": 1, + "image_url": "https://test-image.com/normal.jpg", + "name": "일반음식점" + }, + { + "id": 2, + "image_url": "https://test-image.com/ckicken.jpg", + "name": "치킨" + } + ] + } + """); + } + + @Test + @DisplayName("특정 상점의 이벤트들을 조회한다.") + void getShopEvents() { + eventArticleFixture.할인_이벤트( + shop, + LocalDate.now(clock).minusDays(3), + LocalDate.now(clock).plusDays(3) + ); + eventArticleFixture.참여_이벤트( + shop, + LocalDate.now(clock).minusDays(3), + LocalDate.now(clock).plusDays(3) + ); + + var response = RestAssured + .given() + .when() + .get("/shops/{shopId}/events", shop.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "events": [ + { + "shop_id": 1, + "shop_name": "마슬랜 치킨", + "event_id": 1, + "title": "할인 이벤트", + "content": "사장님이 미쳤어요!", + "thumbnail_images": [ + "https://eventimage.com/할인_이벤트.jpg", + "https://eventimage.com/할인_이벤트.jpg" + ], + "start_date": "2024-01-12", + "end_date": "2024-01-18" + }, + { + "shop_id": 1, + "shop_name": "마슬랜 치킨", + "event_id": 2, + "title": "참여 이벤트", + "content": "사장님과 참여해요!!!", + "thumbnail_images": [ + "https://eventimage.com/참여_이벤트.jpg", + "https://eventimage.com/참여_이벤트.jpg" + ], + "start_date": "2024-01-12", + "end_date": "2024-01-18" + } + ] + } + """); + } + + @Test + @DisplayName("이벤트 진행중인 상점의 정보를 조회한다.") + void getShopWithEvents() { + eventArticleFixture.할인_이벤트( + shop, + LocalDate.now(clock).minusDays(3), + LocalDate.now(clock).plusDays(3) + ); + eventArticleFixture.참여_이벤트( + shop, + LocalDate.now(clock).minusDays(3), + LocalDate.now(clock).plusDays(3) + ); + + var response = RestAssured + .given() + .when() + .get("/shops/{shopId}", shop.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Assertions.assertThat(response.jsonPath().getBoolean("is_event")).isTrue(); + } + + @Test + @DisplayName("이벤트 진행중이지 않은 상점의 정보를 조회한다.") + void getShopWithoutEvents() { + eventArticleFixture.할인_이벤트( + shop, + LocalDate.now(clock).plusDays(3), + LocalDate.now(clock).plusDays(5) + ); + eventArticleFixture.참여_이벤트( + shop, + LocalDate.now(clock).minusDays(5), + LocalDate.now(clock).minusDays(3) + ); + + var response = RestAssured + .given() + .when() + .get("/shops/{shopId}", shop.getId()) + .then() + + .statusCode(HttpStatus.OK.value()) + .extract(); + + Assertions.assertThat(response.jsonPath().getBoolean("is_event")).isFalse(); + } + + @Test + @DisplayName("이벤트 베너 조회") + void ownerShopDeleteEvent() { + eventArticleFixture.참여_이벤트( + shop, + LocalDate.now(clock), + LocalDate.now(clock).plusDays(10) + ); + eventArticleFixture.할인_이벤트( + shop, + LocalDate.now(clock).minusDays(10), + LocalDate.now(clock).minusDays(1) + ); + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .when() + .get("/shops/events") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "events": [ + { + "shop_id": 1, + "shop_name": "마슬랜 치킨", + "event_id": 1, + "title": "참여 이벤트", + "content": "사장님과 참여해요!!!", + "thumbnail_images": [ + "https://eventimage.com/참여_이벤트.jpg", + "https://eventimage.com/참여_이벤트.jpg" + ], + "start_date": "2024-01-15", + "end_date": "2024-01-25" + } + ] + } + """); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java new file mode 100644 index 000000000..29540c248 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java @@ -0,0 +1,606 @@ +package in.koreatech.koin.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetable.model.TimeTable; +import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.LectureFixture; +import in.koreatech.koin.fixture.SemesterFixture; +import in.koreatech.koin.fixture.TimeTableFixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +class TimetableApiTest extends AcceptanceTest { + + @Autowired + private TimeTableRepository timeTableRepository; + + @Autowired + private UserFixture userFixture; + + @Autowired + private LectureFixture lectureFixture; + + @Autowired + private SemesterFixture semesterFixture; + + @Autowired + private TimeTableFixture timeTableFixture; + + @Test + @DisplayName("특정 학기 강의를 조회한다") + void getSemesterLecture() { + String semester = "20201"; + lectureFixture.HRD_개론(semester); + lectureFixture.건축구조의_이해_및_실습("20192"); + + var response = RestAssured + .given() + .when() + .param("semester_date", semester) + .get("/lectures") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "code": "BSM590", + "name": "컴퓨팅사고", + "grades": "3", + "lecture_class": "06", + "regular_number": "22", + "department": "기계공학부", + "target": "기공1", + "professor": "박한수,최준호", + "is_english": "", + "design_score": "0", + "is_elearning": "", + "class_time": [ + 12, 13, 14, 15, 210, 211, 212, 213 + ] + } + ] + """); + } + + @Test + @DisplayName("특정 학기 강의들을 조회한다") + void getSemesterLectures() { + String semester = "20201"; + lectureFixture.HRD_개론(semester); + lectureFixture.건축구조의_이해_및_실습(semester); + lectureFixture.재료역학(semester); + + var response = RestAssured + .given() + .when() + .param("semester_date", semester) + .get("/lectures") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "code": "BSM590", + "name": "컴퓨팅사고", + "grades": "3", + "lecture_class": "06", + "regular_number": "22", + "department": "기계공학부", + "target": "기공1", + "professor": "박한수,최준호", + "is_english": "", + "design_score": "0", + "is_elearning": "", + "class_time": [ + 12, 13, 14, 15, 210, 211, 212, 213 + ] + }, + { + "code": "ARB244", + "name": "건축구조의 이해 및 실습", + "grades": "3", + "lecture_class": "01", + "regular_number": "25", + "department": "디자인ㆍ건축공학부", + "target": "디자 1 건축", + "professor": "황현식", + "is_english": "N", + "design_score": "0", + "is_elearning": "N", + "class_time": [ + 200, 201, 202, 203, 204, 205, 206, 207 + ] + }, + { + "code": "MEB311", + "name": "재료역학", + "grades": "3", + "lecture_class": "01", + "regular_number": "35", + "department": "기계공학부", + "target": "기공전체", + "professor": "허준기", + "is_english": "", + "design_score": "0", + "is_elearning": "", + "class_time": [ + 100, 101, 102, 103, 308, 309 + ] + } + ] + """); + } + + @Test + @DisplayName("존재하지 않는 학기를 조회하면 404") + void isNotSemester() { + String semester = "20201"; + lectureFixture.HRD_개론(semester); + lectureFixture.건축구조의_이해_및_실습(semester); + + RestAssured + .given() + .when() + .param("semester_date", "20193") + .get("/lectures") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .extract(); + } + + @Test + @DisplayName("모든 학기를 조회한다.") + void findAllSemesters() { + semesterFixture.semester("20221"); + semesterFixture.semester("20222"); + semesterFixture.semester("20231"); + semesterFixture.semester("20232"); + + var response = RestAssured + .given() + .when() + .get("/semesters") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "id": 4, + "semester": "20232" + }, + { + "id": 3, + "semester": "20231" + }, + { + "id": 2, + "semester": "20222" + }, + { + "id": 1, + "semester": "20221" + } + ] + """); + } + + @Test + @DisplayName("시간표를 조회한다.") + void getTimeTables() { + // given + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + TimeTable 이산수학 = timeTableFixture.이산수학(user, semester); + TimeTable 알고리즘및실습 = timeTableFixture.알고리즘및실습(user, semester); + TimeTable 컴퓨터구조 = timeTableFixture.컴퓨터구조(user, semester); + + // when & then + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("semester", semester.getSemester()) + .get("/timetables") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(String.format(""" + { + "semester": "20192", + "timetable": [ + { + "id": %d, + "regular_number": "40", + "code": "CSE125", + "design_score": "0", + "class_time": [ + 14, 15, 16, 17, 312, 313 + ], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "이산수학", + "lecture_class": "01", + "target": "컴부전체", + "professor": "서정빈", + "department": "컴퓨터공학부" + }, + { + "id": %d, + "regular_number": "32", + "code": "CSE130", + "design_score": "0", + "class_time": [ + 14, 15, 16, 17, 310, 311, 312, 313 + ], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "알고리즘및실습", + "lecture_class": "03", + "target": "컴부전체", + "professor": "박다희", + "department": "컴퓨터공학부" + }, + { + "id": %d, + "regular_number": "28", + "code": "CS101", + "design_score": "0", + "class_time": [ + 14, 15, 16, 17, 204, 205, 206, 207 + ], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "컴퓨터 구조", + "lecture_class": "02", + "target": "컴부전체", + "professor": "김성재", + "department": "컴퓨터공학부" + } + ], + "grades": 9, + "total_grades": 9 + } + """, 이산수학.getId(), 알고리즘및실습.getId(), 컴퓨터구조.getId() + )); + } + + @Test + @DisplayName("조회된 시간표가 없으면 404에러를 반환한다.") + void getTimeTablesNotFound() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + timeTableFixture.이산수학(user, semester); + timeTableFixture.알고리즘및실습(user, semester); + timeTableFixture.컴퓨터구조(user, semester); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("semester", "20231") + .get("/timetables") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .extract(); + } + + @Test + @DisplayName("시간표를 생성한다.") + void createTimeTables() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "timetable": [ + { + "code": "CPC490", + "class_title": "운영체제", + "class_time": [ + 210, + 211 + ], + "class_place": null, + "professor": "이돈우", + "grades": "3", + "lecture_class": "01", + "target": "디자 1 건축", + "regular_number": "25", + "design_score": "0", + "department": "디자인ㆍ건축공학부", + "memo": null + }, + { + "code": "CSE201", + "class_title": "컴퓨터구조", + "class_time": [ + ], + "class_place": null, + "professor": "이강환", + "grades": "1", + "lecture_class": "02", + "target": "컴퓨 3", + "regular_number": "38", + "design_score": "0", + "department": "컴퓨터공학부", + "memo": null + } + ], + "semester": "%s" + } + """, semester.getSemester() + )) + .when() + .post("/timetables") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "semester": "20192", + "timetable": [ + { + "id": 1, + "regular_number": "25", + "code": "CPC490", + "design_score": "0", + "class_time": [ + 210, 211 + ], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "운영체제", + "lecture_class": "01", + "target": "디자 1 건축", + "professor": "이돈우", + "department": "디자인ㆍ건축공학부" + }, + { + "id": 2, + "regular_number": "38", + "code": "CSE201", + "design_score": "0", + "class_time": [ + + ], + "class_place": null, + "memo": null, + "grades": "1", + "class_title": "컴퓨터구조", + "lecture_class": "02", + "target": "컴퓨 3", + "professor": "이강환", + "department": "컴퓨터공학부" + } + ], + "grades": 4, + "total_grades": 4 + } + """); + } + + @Test + @DisplayName("시간표 생성시 필수 필드를 안넣을때 에러코드 400을 반환한다.") + void createTimeTablesBadRequest() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "timetable": [ + { + "code": "CPC490", + "class_title": null, + "class_time": [ + 210, + 211 + ], + "class_place": null, + "professor": null, + "grades": null, + "lecture_class": "01", + "target": "디자 1 건축", + "regular_number": "25", + "design_score": "0", + "department": "디자인ㆍ건축공학부", + "memo": null + } + ], + "semester": "%s" + } + """, semester.getSemester() + )) + .when() + .post("/timetables") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("시간표 수정한다.") + void updateTimeTables() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + TimeTable timeTable1 = timeTableFixture.이산수학(user, semester); + TimeTable timeTable2 = timeTableFixture.알고리즘및실습(user, semester); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "timetable": [ + { + "id": %d, + "code": "CPC999", + "class_title": "안녕체제", + "class_time": [ + 210, 211 + ], + "class_place": null, + "professor": "차은우", + "grades": "1", + "lecture_class": "01", + "target": "전체", + "regular_number": "25", + "design_score": "0", + "department": "교양학부", + "memo": null + }, + { + "id": %d, + "code": "CSE777", + "class_title": "구조화된컴퓨터", + "class_time": [ + ], + "class_place": null, + "professor": "장원영", + "grades": "1", + "lecture_class": "02", + "target": "컴퓨 3", + "regular_number": "38", + "design_score": "0", + "department": "컴퓨터공학부", + "memo": null + } + ], + "semester": "%s" + } + """, timeTable1.getId(), timeTable2.getId(), semester.getSemester() + )) + .when() + .put("/timetables") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "semester": "20192", + "timetable": [ + { + "id": 1, + "regular_number": "25", + "code": "CPC999", + "design_score": "0", + "class_time": [ + 210, 211 + ], + "class_place": null, + "memo": null, + "grades": "1", + "class_title": "안녕체제", + "lecture_class": "01", + "target": "전체", + "professor": "차은우", + "department": "교양학부" + }, + { + "id": 2, + "regular_number": "38", + "code": "CSE777", + "design_score": "0", + "class_time": [ + + ], + "class_place": null, + "memo": null, + "grades": "1", + "class_title": "구조화된컴퓨터", + "lecture_class": "02", + "target": "컴퓨 3", + "professor": "장원영", + "department": "컴퓨터공학부" + } + ], + "grades": 2, + "total_grades": 2 + } + """); + } + + @Test + @DisplayName("시간표를 삭제한다.") + void deleteTimeTable() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + TimeTable timeTable = timeTableFixture.이산수학(user, semester); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("id", timeTable.getId()) + .delete("/timetable") + .then() + .statusCode(HttpStatus.OK.value()); + + assertThat(timeTableRepository.findById(timeTable.getId())).isNotPresent(); + } + + @Test + @DisplayName("시간표 삭제 실패시(=조회 실패시) 404 에러코드를 반환한다.") + void deleteTimeTableNotFound() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + timeTableFixture.이산수학(user, semester); + timeTableFixture.알고리즘및실습(user, semester); + timeTableFixture.컴퓨터구조(user, semester); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("id", 999) + .delete("/timetable") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java index c11cb9e57..4b4d6ddc1 100644 --- a/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java @@ -1,187 +1,149 @@ package in.koreatech.koin.acceptance; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; -import static org.springframework.test.annotation.DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD; - -import in.koreatech.koin.domain.Member; -import in.koreatech.koin.domain.TechStack; -import in.koreatech.koin.domain.Track; -import in.koreatech.koin.repository.MemberRepository; -import in.koreatech.koin.repository.TechStackRepository; -import in.koreatech.koin.repository.TrackRepository; -import io.restassured.RestAssured; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; -import java.time.format.DateTimeFormatter; -import org.assertj.core.api.SoftAssertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.HttpStatus; -import org.springframework.test.annotation.DirtiesContext; -@SpringBootTest(webEnvironment = RANDOM_PORT) -@DirtiesContext(classMode = BEFORE_EACH_TEST_METHOD) -class TrackApiTest { +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.member.model.Track; +import in.koreatech.koin.fixture.MemberFixture; +import in.koreatech.koin.fixture.TechStackFixture; +import in.koreatech.koin.fixture.TrackFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; - @LocalServerPort - int port; +@SuppressWarnings("NonAsciiCharacters") +class TrackApiTest extends AcceptanceTest { @Autowired - private TrackRepository trackRepository; + private TrackFixture trackFixture; @Autowired - private TechStackRepository techStackRepository; + private MemberFixture memberFixture; @Autowired - private MemberRepository memberRepository; - - @BeforeEach - void setUp() { - RestAssured.port = port; - } + private TechStackFixture techStackFixture; @Test @DisplayName("BCSDLab 트랙 정보를 조회한다") void findTracks() { - Track request = Track.builder().name("BackEnd").build(); - Track track = trackRepository.save(request); + trackFixture.backend(); + trackFixture.frontend(); + trackFixture.ios(); - ExtractableResponse response = RestAssured + var response = RestAssured .given() - .log().all() .when() - .log().all() .get("/tracks") .then() - .log().all() .statusCode(HttpStatus.OK.value()) .extract(); - SoftAssertions.assertSoftly( - softly -> { - softly.assertThat(response.body().jsonPath().getList(".").size()).isEqualTo(1); - softly.assertThat(response.body().jsonPath().getLong("[0].id")).isEqualTo(track.getId()); - softly.assertThat(response.body().jsonPath().getString("[0].name")).isEqualTo(track.getName()); - softly.assertThat(response.body().jsonPath().getInt("[0].headcount")).isEqualTo(track.getHeadcount()); - softly.assertThat(response.body().jsonPath().getBoolean("[0].is_deleted")) - .isEqualTo(track.getIsDeleted()); - softly.assertThat(response.body().jsonPath().getString("[0].created_at")) - .isEqualTo(track.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); - softly.assertThat(response.body().jsonPath().getString("[0].updated_at")) - .isEqualTo(track.getUpdatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); - } - ); + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "id": 1, + "name": "BackEnd", + "headcount": 0, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + { + "id": 2, + "name": "FrontEnd", + "headcount": 0, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + { + "id": 3, + "name": "iOS", + "headcount": 0, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ] + """); } @Test @DisplayName("BCSDLab 트랙 정보 단건 조회") void findTrack() { - Track track = Track.builder().name("BackEnd").build(); - trackRepository.save(track); - - Member member = Member.builder() - .isDeleted(false) - .studentNumber("2019136064") - .imageUrl("https://imagetest.com/asdf.jpg") - .name("박한수") - .position("Regular") - .trackId(track.getId()) - .email("hsp@gmail.com") - .build(); - memberRepository.save(member); + Track track = trackFixture.backend(); + memberFixture.최준호(track); + techStackFixture.java(track); - TechStack techStack = TechStack.builder() - .isDeleted(false) - .imageUrl("https://testimageurl.com") - .trackId(track.getId()) - .name("Java") - .description("Language") - .build(); - techStackRepository.save(techStack); - - ExtractableResponse response = RestAssured + var response = RestAssured .given() - .log().all() .when() - .log().all() .get("/tracks/{id}", track.getId()) .then() - .log().all() .statusCode(HttpStatus.OK.value()) .extract(); - SoftAssertions.assertSoftly( - softly -> { - softly.assertThat(response.body().jsonPath().getString("TrackName")).isEqualTo(track.getName()); - - softly.assertThat(response.body().jsonPath().getList("Members")).hasSize(1); - softly.assertThat(response.body().jsonPath().getInt("Members[0].id")).isEqualTo(member.getId()); - softly.assertThat(response.body().jsonPath().getString("Members[0].name")).isEqualTo(member.getName()); - softly.assertThat(response.body().jsonPath().getString("Members[0].student_number")) - .isEqualTo(member.getStudentNumber()); - softly.assertThat(response.body().jsonPath().getString("Members[0].position")) - .isEqualTo(member.getPosition()); - softly.assertThat(response.body().jsonPath().getString("Members[0].track")) - .isEqualTo(track.getName()); - softly.assertThat(response.body().jsonPath().getString("Members[0].email")) - .isEqualTo(member.getEmail()); - softly.assertThat(response.body().jsonPath().getString("Members[0].image_url")) - .isEqualTo(member.getImageUrl()); - softly.assertThat(response.body().jsonPath().getBoolean("Members[0].is_deleted")) - .isEqualTo(member.getIsDeleted()); - softly.assertThat(response.body().jsonPath().getString("Members[0].updated_at")) - .isEqualTo(member.getUpdatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); - softly.assertThat(response.body().jsonPath().getString("Members[0].created_at")) - .isEqualTo(member.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); - - softly.assertThat(response.body().jsonPath().getList("TechStacks")).hasSize(1); - softly.assertThat(response.body().jsonPath().getLong("TechStacks[0].id")).isEqualTo(techStack.getId()); - softly.assertThat(response.body().jsonPath().getString("TechStacks[0].image_url")) - .isEqualTo(techStack.getImageUrl()); - softly.assertThat(response.body().jsonPath().getLong("TechStacks[0].track_id")) - .isEqualTo(techStack.getTrackId()); - softly.assertThat(response.body().jsonPath().getString("TechStacks[0].name")) - .isEqualTo(techStack.getName()); - softly.assertThat(response.body().jsonPath().getString("TechStacks[0].description")) - .isEqualTo(techStack.getDescription()); - softly.assertThat(response.body().jsonPath().getBoolean("TechStacks[0].is_deleted")).isFalse(); - softly.assertThat(response.body().jsonPath().getString("TechStacks[0].updated_at")) - .isEqualTo(techStack.getUpdatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); - softly.assertThat(response.body().jsonPath().getString("TechStacks[0].created_at")) - .isEqualTo(techStack.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); - - } - ); + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "TrackName": "BackEnd", + "TechStacks": [ + { + "id": 1, + "name": "Java", + "description": "Language", + "image_url": "https://testimageurl.com", + "track_id": 1, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ], + "Members": [ + { + "id": 1, + "name": "최준호", + "student_number": "2019136135", + "position": "Regular", + "track": "BackEnd", + "email": "testjuno@gmail.com", + "image_url": "https://imagetest.com/juno.jpg", + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ] + } + """); } @Test @DisplayName("BCSDLab 트랙 정보 단건 조회 - 트랙에 속한 멤버와 기술스택이 없을 때") void findTrackWithEmptyMembersAndTechStacks() { - Track track = Track.builder().name("BackEnd").build(); - trackRepository.save(track); + Track track = trackFixture.frontend(); - ExtractableResponse response = RestAssured + var response = RestAssured .given() - .log().all() .when() - .log().all() .get("/tracks/{id}", track.getId()) .then() - .log().all() .statusCode(HttpStatus.OK.value()) .extract(); - SoftAssertions.assertSoftly( - softly -> { - softly.assertThat(response.body().jsonPath().getString("TrackName")).isEqualTo(track.getName()); - - softly.assertThat(response.body().jsonPath().getList("Members")).hasSize(0); - softly.assertThat(response.body().jsonPath().getList("TechStacks")).hasSize(0); - } - ); + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "TrackName": "FrontEnd", + "TechStacks": [ + + ], + "Members": [ + + ] + } + """); } } diff --git a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java new file mode 100644 index 000000000..b5f434166 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java @@ -0,0 +1,743 @@ +package in.koreatech.koin.acceptance; + +import static in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallbackWithoutResult; +import org.springframework.transaction.support.TransactionTemplate; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.dept.model.Dept; +import in.koreatech.koin.domain.user.model.Student; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserGender; +import in.koreatech.koin.domain.user.repository.StudentRepository; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +class UserApiTest extends AcceptanceTest { + + @Autowired + private StudentRepository studentRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private UserFixture userFixture; + + @Test + @DisplayName("올바른 영양사 계정인지 확인한다") + void coopCheckMe() { + User user = userFixture.준기_영양사(); + String token = userFixture.getToken(user); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/user/coop/me") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + } + + @Test + @DisplayName("올바른 학생계정인지 확인한다") + void studentCheckMe() { + Student student = userFixture.준호_학생(); + String token = userFixture.getToken(student.getUser()); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/user/student/me") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "anonymous_nickname": "익명", + "email": "juno@koreatech.ac.kr", + "gender": 0, + "major": "컴퓨터공학부", + "name": "테스트용_준호", + "nickname": "준호", + "phone_number": "010-1234-5678", + "student_number": "2019136135" + } + """); + } + + @Test + @DisplayName("올바른 학생계정인지 확인한다 - 토큰 정보가 올바르지 않으면 401") + void studentCheckMeUnAuthorized() { + userFixture.준호_학생(); + String token = "invalidToken"; + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/user/student/me") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .extract(); + } + + @Test + @DisplayName("올바른 학생계정인지 확인한다 - 회원을 찾을 수 없으면 404") + void studentCheckMeNotFound() { + Student student = userFixture.준호_학생(); + String token = jwtProvider.createToken(student.getUser()); + transactionTemplate.executeWithoutResult(status -> + studentRepository.deleteByUserId(student.getId()) + ); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/user/student/me") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .extract(); + } + + @Test + @DisplayName("학생이 정보를 수정한다") + void studentUpdateMe() { + Student student = userFixture.준호_학생(); + String token = userFixture.getToken(student.getUser()); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "gender" : 1, + "major" : "기계공학부", + "name" : "서정빈", + "password" : "0c4be6acaba1839d3433c1ccf04e1eec4d1fa841ee37cb019addc269e8bc1b77", + "nickname" : "duehee", + "phone_number" : "010-2345-6789", + "student_number" : "2019136136" + } + """) + .when() + .put("/user/student/me") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Student result = studentRepository.getById(student.getId()); + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(result.getUser().getName()).isEqualTo("서정빈"); + softly.assertThat(result.getUser().getNickname()).isEqualTo("duehee"); + softly.assertThat(result.getUser().getName()).isEqualTo("서정빈"); + softly.assertThat(result.getUser().getGender()).isEqualTo(UserGender.from(1)); + softly.assertThat(result.getStudentNumber()).isEqualTo("2019136136"); + } + ); + }); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "anonymous_nickname": "익명", + "email": "juno@koreatech.ac.kr", + "gender": 1, + "major": "기계공학부", + "name": "서정빈", + "nickname": "duehee", + "phone_number": "010-2345-6789", + "student_number": "2019136136" + } + """); + } + + @Test + @DisplayName("학생이 정보를 수정한다 - 학번의 형식이 맞지 않으면 400") + void studentUpdateMeNotValidStudentNumber() { + Student student = userFixture.준호_학생(); + String token = userFixture.getToken(student.getUser()); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "gender" : 0, + "major" : "메카트로닉스공학부", + "name" : "최주노", + "nickname" : "juno", + "phone_number" : "010-2345-6789", + "student_number" : "201913613" + } + """) + .when() + .put("/user/student/me") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("학생이 정보를 수정한다 - 학부의 형식이 맞지 않으면 400") + void studentUpdateMeNotValidDepartment() { + Student student = userFixture.준호_학생(); + String token = userFixture.getToken(student.getUser()); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "gender" : 0, + "major" : "경영학과", + "name" : "최주노", + "nickname" : "juno", + "phone_number" : "010-2345-6789", + "student_number" : "2019136136" + } + """) + .when() + .put("/user/student/me") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("학생이 정보를 수정한다 - 토큰이 올바르지 않다면 401") + void studentUpdateMeUnAuthorized() { + userFixture.준호_학생(); + String token = "invalidToken"; + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "gender" : 0, + "major" : "메카트로닉스공학부", + "name" : "최주노", + "nickname" : "juno", + "phone_number" : "010-2345-6789", + "student_number" : "2019136136" + } + """) + .when() + .put("/user/student/me") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .extract(); + } + + @Test + @DisplayName("학생이 정보를 수정한다 - 회원을 찾을 수 없다면 404") + void studentUpdateMeNotFound() { + Student student = userFixture.준호_학생(); + String token = userFixture.getToken(student.getUser()); + transactionTemplate.executeWithoutResult(status -> + studentRepository.deleteByUserId(student.getId()) + ); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "gender" : 0, + "major" : "메카트로닉스공학부", + "name" : "최주노", + "nickname" : "juno", + "phone_number" : "010-2345-6789", + "student_number" : "2019136136" + } + """) + .when() + .put("/user/student/me") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .extract(); + } + + @Test + @DisplayName("학생이 정보를 수정한다 - 이미 있는 닉네임이라면 409") + void studentUpdateMeDuplicationNickname() { + Student 준호 = userFixture.준호_학생(); + Student 성빈 = userFixture.성빈_학생(); + String token = userFixture.getToken(준호.getUser()); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "gender" : 0, + "major" : "테스트학과", + "name" : "최주노", + "nickname" : "%s", + "phone_number" : "010-2345-6789", + "student_number" : "2019136136" + } + """, 성빈.getUser().getNickname())) + .when() + .put("/user/student/me") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + } + + @Test + @DisplayName("회원이 탈퇴한다") + void userWithdraw() { + Student student = userFixture.성빈_학생(); + String token = userFixture.getToken(student.getUser()); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/user") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + assertThat(userRepository.findById(student.getId())).isNotPresent(); + } + + @Test + @DisplayName("이메일이 중복인지 확인한다") + void emailCheckExists() { + String email = "notduplicated@koreatech.ac.kr"; + + RestAssured + .given() + .param("address", email) + .when() + .get("/user/check/email") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(userRepository.findByEmail(email)).isNotPresent(); + } + + @Test + @DisplayName("이메일이 중복인지 확인한다 - 파라미터에 이메일을 포함하지 않으면 400") + void emailCheckExistsNull() { + RestAssured + .when() + .get("/user/check/email") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("이메일이 중복인지 확인한다 - 잘못된 이메일 형식이면 400") + void emailCheckExistsWrongFormat() { + String email = "wrong email format"; + + RestAssured + .given() + .param("address", email) + .when() + .get("/user/check/email") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("이메일이 중복인지 확인한다 - 중복이면 422") + void emailCheckExistsAlreadyExists() { + User user = userFixture.성빈_학생().getUser(); + + var response = RestAssured + .given() + .param("address", user.getEmail()) + .when() + .get("/user/check/email") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + + assertThat(response.body().jsonPath().getString("message")) + .contains("존재하는 이메일입니다."); + } + + @Test + @DisplayName("닉네임 중복일때 상태코드 409를 반환한다.") + void checkDuplicationOfNicknameConflict() { + User user = userFixture.성빈_학생().getUser(); + + var response = RestAssured + .given() + .when() + .param("nickname", user.getNickname()) + .get("/user/check/nickname") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + + assertThat(response.body().jsonPath().getString("message")) + .contains("이미 존재하는 닉네임입니다."); + } + + @Test + @DisplayName("닉네임 중복이 아닐시 상태코드 200을 반환한다.") + void checkDuplicationOfNickname() { + User user = userFixture.성빈_학생().getUser(); + + RestAssured + .given() + .when() + .param("nickname", "철수") + .get("/user/check/nickname") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + } + + @Test + @DisplayName("닉네임 제약조건 위반시 상태코드 400를 반환한다.") + void checkDuplicationOfNicknameBadRequest() { + User user = User.builder() + .password("1234") + .nickname("주노") + .name("최준호") + .phoneNumber("010-1234-5678") + .userType(STUDENT) + .gender(UserGender.MAN) + .email("test@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build(); + + userRepository.save(user); + + RestAssured + .given() + .when() + .param("nickname", "철".repeat(11)) + .get("/user/check/nickname") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + + RestAssured + .given() + .when() + .param("nickname", "") + .get("/user/check/nickname") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("로그인된 사용자의 권한을 조회한다.") + void getAuth() { + Student student = Student.builder() + .studentNumber("2019136135") + .anonymousNickname("익명") + .department("컴퓨터공학부") + .userIdentity(UNDERGRADUATE) + .isGraduated(false) + .user( + User.builder() + .password("1234") + .nickname("주노") + .name("최준호") + .phoneNumber("010-1234-5678") + .userType(STUDENT) + .gender(UserGender.MAN) + .email("test@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build() + ) + .build(); + + studentRepository.save(student); + String token = jwtProvider.createToken(student.getUser()); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/user/auth") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + User user = student.getUser(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getString("user_type")) + .isEqualTo(user.getUserType().getValue()); + } + ); + } + + @Test + @DisplayName("학생 회원가입 후 학교 이메일요청 이벤트가 발생한다.") + void studentRegister() { + RestAssured + .given() + .body(""" + { + "major": "컴퓨터공학부", + "email": "koko123@koreatech.ac.kr", + "name": "김철수", + "password": "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + "nickname": "koko", + "gender": "0", + "is_graduated": false, + "student_number": "2021136012", + "phone_number": "010-0000-0000" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/user/student/register") + .then() + .statusCode(HttpStatus.OK.value()); + + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + User user = userRepository.getByEmail("koko123@koreatech.ac.kr"); + Student student = studentRepository.getById(user.getId()); + + assertSoftly( + softly -> { + softly.assertThat(student).isNotNull(); + softly.assertThat(student.getUser().getNickname()).isEqualTo("koko"); + softly.assertThat(student.getUser().getName()).isEqualTo("김철수"); + softly.assertThat(student.getUser().getPhoneNumber()).isEqualTo("010-0000-0000"); + softly.assertThat(student.getUser().getUserType()).isEqualTo(STUDENT); + softly.assertThat(student.getUser().getEmail()).isEqualTo("koko123@koreatech.ac.kr"); + softly.assertThat(student.getUser().isAuthed()).isEqualTo(false); + softly.assertThat(student.getStudentNumber()).isEqualTo("2021136012"); + softly.assertThat(student.getDepartment()).isEqualTo(Dept.COMPUTER_SCIENCE.getName()); + softly.assertThat(student.getAnonymousNickname()).isNotNull(); + verify(studentEventListener).onStudentEmailRequest(any()); + } + ); + } + }); + } + + @Test + @DisplayName("이메일 요청을 확인 후 회원가입 이벤트가 발생한다.") + void authenticate() { + RestAssured + .given() + .body(""" + { + "major": "컴퓨터공학부", + "email": "koko123@koreatech.ac.kr", + "name": "김철수", + "password": "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + "nickname": "koko", + "gender": "0", + "is_graduated": false, + "student_number": "2021136012", + "phone_number": "010-0000-0000" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/user/student/register") + .then() + .statusCode(HttpStatus.OK.value()); + + User user = userRepository.getByEmail("koko123@koreatech.ac.kr"); + + RestAssured + .given() + .param("auth_token", user.getAuthToken()) + .when() + .get("/user/authenticate"); + + User user1 = userRepository.getByEmail("koko123@koreatech.ac.kr"); + + assertThat(user1.isAuthed()).isTrue(); + verify(studentEventListener).onStudentRegister(any()); + } + + @Test + @DisplayName("회원 가입 필수 파라미터를 안넣을시 400에러코드를 반환한다.") + void studentRegisterBadRequest() { + RestAssured + .given() + .body(""" + { + "major": "컴퓨터공학부", + "email": null, + "name": "김철수", + "password": "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + "nickname": "koko", + "gender": "0", + "is_graduated": false, + "student_number": "2021136012", + "phone_number": "010-0000-0000" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/user/student/register") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("한기대 이메일이 아닐시 400에러코드를 반환한다.") + void studentRegisterInvalid() { + RestAssured + .given() + .body(""" + { + "major": "컴퓨터공학부", + "email": "koko123@gmail.com", + "name": "김철수", + "password": "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + "nickname": "koko", + "gender": "0", + "is_graduated": false, + "student_number": "2021136012", + "phone_number": "010-0000-0000" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/user/student/register") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("유효한 학번의 형식이 아닐시 400에러코드를 반환한다.") + void studentRegisterStudentNumberInvalid() { + RestAssured + .given() + .body(""" + { + "major": "컴퓨터공학부", + "email": "koko123@gmail.com", + "name": "김철수", + "password": "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + "nickname": "koko", + "gender": "0", + "is_graduated": false, + "student_number": "20211360123324231", + "phone_number": "010-0000-0000" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/user/student/register") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + + RestAssured + .given() + .body(""" + { + "major": "컴퓨터공학부", + "email": "koko123@gmail.com", + "name": "김철수", + "password": "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + "nickname": "koko", + "gender": "0", + "is_graduated": false, + "student_number": "19911360123", + "phone_number": "010-0000-0000" + } + """) + .contentType(ContentType.JSON) + .when() + .post("/user/student/register") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("사용자가 비밀번호를 통해 자신이 맞는지 인증한다.") + void userCheckPassword() { + Student student = userFixture.준호_학생(); + String token = userFixture.getToken(student.getUser()); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "password": "1234" + } + """) + .when() + .post("/user/check/password") + .then() + .statusCode(HttpStatus.OK.value()); + } + + @Test + @DisplayName("사용자가 비밀번호를 통해 자신이 맞는지 인증한다. - 비밀번호가 다르면 401 반환") + void userCheckPasswordInvalid() { + Student student = userFixture.준호_학생(); + String token = userFixture.getToken(student.getUser()); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "password": "1233" + } + """) + .when() + .post("/user/check/password") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/VersionApiTest.java b/src/test/java/in/koreatech/koin/acceptance/VersionApiTest.java new file mode 100644 index 000000000..aa03398a0 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/VersionApiTest.java @@ -0,0 +1,74 @@ +package in.koreatech.koin.acceptance; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.version.model.Version; +import in.koreatech.koin.domain.version.model.VersionType; +import in.koreatech.koin.domain.version.repository.VersionRepository; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; + +@SuppressWarnings("NonAsciiCharacters") +class VersionApiTest extends AcceptanceTest { + + @Autowired + private VersionRepository versionRepository; + + @Test + @DisplayName("버전 타입을 통해 버전 정보를 조회한다.") + void findVersionByType() { + Version version = versionRepository.save( + Version.builder() + .version("1.0.0") + .type(VersionType.ANDROID.getValue()) + .build() + ); + + // when then + var response = RestAssured + .given() + .when() + .get("/versions/" + version.getType()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(String.format(""" + { + "id": %d, + "version": "1.0.0", + "type": "android", + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15" + } + """, version.getId() + )); + } + + @Test + @DisplayName("버전 타입을 통해 버전 정보를 조회한다. - 저장되지 않은 버전 타입을 요청한 경우 에러가 발생한다.") + void findVersionByTypeError() { + VersionType failureType = VersionType.TIMETABLE; + RestAssured + .given() + .when() + .get("/versions/" + failureType.getValue()) + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .extract(); + + String undefinedType = "undefined"; + RestAssured + .given() + .when() + .get("/versions/" + undefinedType) + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .extract(); + } +} diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java new file mode 100644 index 000000000..1defa4edd --- /dev/null +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java @@ -0,0 +1,58 @@ +package in.koreatech.koin.admin.acceptance; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.land.repository.AdminLandRepository; +import in.koreatech.koin.domain.land.model.Land; +import io.restassured.RestAssured; + +@SuppressWarnings("NonAsciiCharacters") +class AdminLandApiTest extends AcceptanceTest { + + @Autowired + private AdminLandRepository adminLandRepository; + + @Test + @DisplayName("관리자 권한으로 복덕방 목록을 검색한다.") + void getLands() { + for (int i = 0; i < 11; i++) { + Land request = Land.builder() + .internalName("복덕방" + i) + .name("복덕방" + i) + .roomType("원룸") + .latitude("37.555") + .longitude("126.555") + .monthlyFee("100") + .charterFee("1000") + .build(); + + adminLandRepository.save(request); + } + + var response = RestAssured + .given() + .when() + .param("page", 1) + .param("is_deleted", false) + .get("/admin/lands") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getInt("total_count")).isEqualTo(11); + softly.assertThat(response.body().jsonPath().getInt("current_count")).isEqualTo(10); + softly.assertThat(response.body().jsonPath().getInt("total_page")).isEqualTo(2); + softly.assertThat(response.body().jsonPath().getInt("current_page")).isEqualTo(1); + softly.assertThat(response.body().jsonPath().getList("lands").size()).isEqualTo(10); + } + ); + } +} diff --git a/src/test/java/in/koreatech/koin/config/TestJpaConfiguration.java b/src/test/java/in/koreatech/koin/config/TestJpaConfiguration.java new file mode 100644 index 000000000..8f915ea63 --- /dev/null +++ b/src/test/java/in/koreatech/koin/config/TestJpaConfiguration.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@TestConfiguration +@EnableJpaAuditing(dateTimeProviderRef = "dateTimeProvider") +public class TestJpaConfiguration { + +} diff --git a/src/test/java/in/koreatech/koin/config/TestTimeConfig.java b/src/test/java/in/koreatech/koin/config/TestTimeConfig.java new file mode 100644 index 000000000..4b20885ff --- /dev/null +++ b/src/test/java/in/koreatech/koin/config/TestTimeConfig.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.config; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.auditing.DateTimeProvider; + +@TestConfiguration +public class TestTimeConfig { + + private final LocalDateTime fixedTime = LocalDateTime.of(2024, 1, 15, 12, 0); + + @Bean + public DateTimeProvider dateTimeProvider() { + return () -> Optional.of(fixedTime); + } + + @Bean + public Clock clock() { + return Clock.fixed( + fixedTime.atZone(Clock.systemDefaultZone().getZone()).toInstant(), + Clock.systemDefaultZone().getZone() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/ActivityFixture.java b/src/test/java/in/koreatech/koin/fixture/ActivityFixture.java new file mode 100644 index 000000000..9f1009cad --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/ActivityFixture.java @@ -0,0 +1,70 @@ +package in.koreatech.koin.fixture; + +import java.time.LocalDate; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.activity.model.Activity; +import in.koreatech.koin.domain.activity.repository.ActivityRepository; + +@Component +public final class ActivityFixture { + + private final ActivityRepository activityRepository; + + @Autowired + public ActivityFixture(ActivityRepository activityRepository) { + this.activityRepository = activityRepository; + } + + public ActivityFixtureBuilder builder() { + return new ActivityFixtureBuilder(); + } + + public final class ActivityFixtureBuilder { + + private String title; + private String description; + private String imageUrls; + private LocalDate date; + private boolean isDeleted; + + public ActivityFixtureBuilder title(String title) { + this.title = title; + return this; + } + + public ActivityFixtureBuilder description(String description) { + this.description = description; + return this; + } + + public ActivityFixtureBuilder imageUrls(String imageUrls) { + this.imageUrls = imageUrls; + return this; + } + + public ActivityFixtureBuilder date(LocalDate date) { + this.date = date; + return this; + } + + public ActivityFixtureBuilder isDeleted(boolean isDeleted) { + this.isDeleted = isDeleted; + return this; + } + + public Activity build() { + return activityRepository.save( + Activity.builder() + .title(title) + .description(description) + .imageUrls(imageUrls) + .date(date) + .isDeleted(isDeleted) + .build() + ); + } + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/ArticleFixture.java b/src/test/java/in/koreatech/koin/fixture/ArticleFixture.java new file mode 100644 index 000000000..257993d42 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/ArticleFixture.java @@ -0,0 +1,165 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.community.model.Article; +import in.koreatech.koin.domain.community.model.Board; +import in.koreatech.koin.domain.community.repository.ArticleRepository; +import in.koreatech.koin.domain.user.model.User; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class ArticleFixture { + + private final ArticleRepository articleRepository; + + public ArticleFixture(ArticleRepository articleRepository) { + this.articleRepository = articleRepository; + } + + public Article 자유글_1(User user, Board board) { + return articleRepository.save( + Article.builder() + .board(board) + .title("자유 글의 제목입니다") + .content("

내용

") + .user(user) + .nickname(user.getNickname()) + .hit(1) + .ip("123.21.234.321") + .isSolved(false) + .isDeleted(false) + .commentCount((byte)0) + .meta(null) + .isNotice(false) + .noticeArticleId(null) + .build() + ); + } + + public Article 자유글_2(User user, Board board) { + return articleRepository.save( + Article.builder() + .board(board) + .title("자유 글2의 제목입니다") + .content("

내용222

") + .user(user) + .nickname(user.getNickname()) + .hit(1) + .ip("127.0.0.1") + .isSolved(false) + .isDeleted(false) + .commentCount((byte)0) + .meta(null) + .isNotice(false) + .noticeArticleId(null) + .build() + ); + } + + public ArticleFixtureBuilder builder() { + return new ArticleFixtureBuilder(); + } + + public final class ArticleFixtureBuilder { + + private Board board; + private String title; + private String content; + private User user; + private String nickname; + private Integer hit; + private String ip; + private boolean isSolved; + private boolean isDeleted; + private Byte commentCount; + private String meta; + private boolean isNotice; + private Integer noticeArticleId; + + public ArticleFixtureBuilder board(Board board) { + this.board = board; + return this; + } + + public ArticleFixtureBuilder title(String title) { + this.title = title; + return this; + } + + public ArticleFixtureBuilder content(String content) { + this.content = content; + return this; + } + + public ArticleFixtureBuilder user(User user) { + this.user = user; + return this; + } + + public ArticleFixtureBuilder nickname(String nickname) { + this.nickname = nickname; + return this; + } + + public ArticleFixtureBuilder hit(Integer hit) { + this.hit = hit; + return this; + } + + public ArticleFixtureBuilder ip(String ip) { + this.ip = ip; + return this; + } + + public ArticleFixtureBuilder isSolved(boolean isSolved) { + this.isSolved = isSolved; + return this; + } + + public ArticleFixtureBuilder isDeleted(boolean isDeleted) { + this.isDeleted = isDeleted; + return this; + } + + public ArticleFixtureBuilder commentCount(Byte commentCount) { + this.commentCount = commentCount; + return this; + } + + public ArticleFixtureBuilder meta(String meta) { + this.meta = meta; + return this; + } + + public ArticleFixtureBuilder isNotice(boolean isNotice) { + this.isNotice = isNotice; + return this; + } + + public ArticleFixtureBuilder noticeArticleId(Integer noticeArticleId) { + this.noticeArticleId = noticeArticleId; + return this; + } + + public Article build() { + return articleRepository.save( + Article.builder() + .commentCount(commentCount) + .ip(ip) + .title(title) + .meta(meta) + .isSolved(isSolved) + .noticeArticleId(noticeArticleId) + .content(content) + .board(board) + .user(user) + .nickname(nickname) + .isNotice(isNotice) + .hit(hit) + .isDeleted(isDeleted) + .build() + ); + } + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/BoardFixture.java b/src/test/java/in/koreatech/koin/fixture/BoardFixture.java new file mode 100644 index 000000000..f3e437f1f --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/BoardFixture.java @@ -0,0 +1,106 @@ +package in.koreatech.koin.fixture; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.community.model.Board; +import in.koreatech.koin.domain.community.repository.BoardRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class BoardFixture { + + @Autowired + private final BoardRepository boardRepository; + + @Autowired + public BoardFixture(BoardRepository boardRepository) { + this.boardRepository = boardRepository; + } + + public Board 자유게시판() { + return boardRepository.save( + Board.builder() + .tag("FA001") + .name("자유게시판") + .isAnonymous(false) + .articleCount(0) + .isDeleted(false) + .isNotice(false) + .parentId(null) + .seq(1) + .build() + ); + } + + public BoardFixtureBuilder builder() { + return new BoardFixtureBuilder(); + } + + public final class BoardFixtureBuilder { + + private String tag; + private String name; + private boolean isAnonymous; + private Integer articleCount; + private boolean isDeleted; + private boolean isNotice; + private Integer parentId; + private Integer seq; + + public BoardFixtureBuilder tag(String tag) { + this.tag = tag; + return this; + } + + public BoardFixtureBuilder name(String name) { + this.name = name; + return this; + } + + public BoardFixtureBuilder isAnonymous(boolean isAnonymous) { + this.isAnonymous = isAnonymous; + return this; + } + + public BoardFixtureBuilder articleCount(Integer articleCount) { + this.articleCount = articleCount; + return this; + } + + public BoardFixtureBuilder isDeleted(boolean isDeleted) { + this.isDeleted = isDeleted; + return this; + } + + public BoardFixtureBuilder isNotice(boolean isNotice) { + this.isNotice = isNotice; + return this; + } + + public BoardFixtureBuilder parentId(Integer parentId) { + this.parentId = parentId; + return this; + } + + public BoardFixtureBuilder seq(Integer seq) { + this.seq = seq; + return this; + } + + public Board build() { + return boardRepository.save( + Board.builder() + .tag(tag) + .isDeleted(isDeleted) + .isAnonymous(isAnonymous) + .parentId(parentId) + .seq(seq) + .isNotice(isNotice) + .name(name) + .articleCount(articleCount) + .build() + ); + } + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/BusFixture.java b/src/test/java/in/koreatech/koin/fixture/BusFixture.java new file mode 100644 index 000000000..c114335dd --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/BusFixture.java @@ -0,0 +1,60 @@ +package in.koreatech.koin.fixture; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.bus.model.mongo.BusCourse; +import in.koreatech.koin.domain.bus.model.mongo.Route; +import in.koreatech.koin.domain.bus.repository.BusRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public final class BusFixture { + + @Autowired + private final BusRepository busRepository; + + public BusFixture(BusRepository busRepository) { + this.busRepository = busRepository; + } + + public void 버스_시간표_등록() { + busRepository.save( + BusCourse.builder() + .busType("shuttle") + .region("천안") + .direction("from") + .routes( + List.of( + Route.builder() + .routeName("주중") + .runningDays(List.of("MON", "TUE", "WED", "THU", "FRI")) + .arrivalInfos( + List.of( + Route.ArrivalNode.builder() + .nodeName("한기대") + .arrivalTime("18:10") + .build(), + Route.ArrivalNode.builder() + .nodeName("신계초,운전리,연춘리") + .arrivalTime("정차") + .build(), + Route.ArrivalNode.builder() + .nodeName("천안역(학화호두과자)") + .arrivalTime("18:50") + .build(), + Route.ArrivalNode.builder() + .nodeName("터미널(신세계 앞 횡단보도)") + .arrivalTime("18:55") + .build() + ) + ) + .build() + ) + ) + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/DiningFixture.java b/src/test/java/in/koreatech/koin/fixture/DiningFixture.java new file mode 100644 index 000000000..9bd25138a --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/DiningFixture.java @@ -0,0 +1,81 @@ +package in.koreatech.koin.fixture; + +import static in.koreatech.koin.domain.dining.model.DiningType.LUNCH; + +import java.time.LocalDate; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.repository.DiningRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class DiningFixture { + + private final DiningRepository diningRepository; + + public DiningFixture(DiningRepository diningRepository) { + this.diningRepository = diningRepository; + } + + public Dining 능수관_점심(LocalDate date) { + return diningRepository.save( + Dining.builder() + .date(date) + .type(LUNCH) + .place("능수관") + .priceCard(6000) + .priceCash(6000) + .kcal(300) + .menu(""" + ["참치김치볶음밥", "유부된장국", "땡초부추전", "누룽지탕"]""") + .build() + ); + } + + public Dining 캠퍼스2_점심(LocalDate date) { + return diningRepository.save( + Dining.builder() + .date(date) + .type(LUNCH) + .place("2캠퍼스") + .priceCard(6000) + .priceCash(6000) + .kcal(881) + .menu(""" + ["혼합잡곡밥", "가쓰오장국", "땡초부추전", "누룽지탕"]""") + .build() + ); + } + + public Dining A코스_점심(LocalDate date) { + return diningRepository.save( + Dining.builder() + .date(date) + .type(LUNCH) + .place("A코스") + .priceCard(6000) + .priceCash(6000) + .kcal(881) + .menu(""" + ["병아리콩밥", "(탕)소고기육개장", "땡초부추전", "누룽지탕"]""") + .build() + ); + } + + public Dining B코스_점심(LocalDate date) { + return diningRepository.save( + Dining.builder() + .date(date) + .type(LUNCH) + .place("B코스") + .priceCard(6000) + .priceCash(6000) + .kcal(881) + .menu(""" + ["병아리", "소고기", "땡초", "탕"]""") + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/EventArticleFixture.java b/src/test/java/in/koreatech/koin/fixture/EventArticleFixture.java new file mode 100644 index 000000000..308325451 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/EventArticleFixture.java @@ -0,0 +1,90 @@ +package in.koreatech.koin.fixture; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.shop.model.EventArticle; +import in.koreatech.koin.domain.shop.model.EventArticleImage; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.repository.EventArticleRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class EventArticleFixture { + + private final EventArticleRepository eventArticleRepository; + + public EventArticleFixture( + EventArticleRepository eventArticleRepository + ) { + this.eventArticleRepository = eventArticleRepository; + } + + public EventArticle 할인_이벤트( + Shop shop, + LocalDate startDate, + LocalDate endDate + ) { + EventArticle eventArticle = eventArticleRepository.save( + EventArticle.builder() + .shop(shop) + .title("할인 이벤트") + .content("사장님이 미쳤어요!") + .ip("") + .startDate(startDate) + .endDate(endDate) + .hit(0) + .build() + ); + + eventArticle.getThumbnailImages() + .addAll( + List.of( + EventArticleImage.builder() + .thumbnailImage("https://eventimage.com/할인_이벤트.jpg") + .eventArticle(eventArticle) + .build(), + EventArticleImage.builder() + .thumbnailImage("https://eventimage.com/할인_이벤트.jpg") + .eventArticle(eventArticle) + .build() + ) + ); + return eventArticleRepository.save(eventArticle); + } + + public EventArticle 참여_이벤트( + Shop shop, + LocalDate startDate, + LocalDate endDate + ) { + EventArticle eventArticle = eventArticleRepository.save( + EventArticle.builder() + .shop(shop) + .title("참여 이벤트") + .content("사장님과 참여해요!!!") + .ip("") + .startDate(startDate) + .endDate(endDate) + .hit(0) + .build() + ); + + eventArticle.getThumbnailImages() + .addAll( + List.of( + EventArticleImage.builder() + .thumbnailImage("https://eventimage.com/참여_이벤트.jpg") + .eventArticle(eventArticle) + .build(), + EventArticleImage.builder() + .thumbnailImage("https://eventimage.com/참여_이벤트.jpg") + .eventArticle(eventArticle) + .build() + ) + ); + return eventArticleRepository.save(eventArticle); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/LandFixture.java b/src/test/java/in/koreatech/koin/fixture/LandFixture.java new file mode 100644 index 000000000..95b1e50a1 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/LandFixture.java @@ -0,0 +1,65 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.land.model.Land; +import in.koreatech.koin.domain.land.repository.LandRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class LandFixture { + + private final LandRepository landRepository; + + public LandFixture(LandRepository landRepository) { + this.landRepository = landRepository; + } + + public Land 신안빌() { + return landRepository.save( + Land.builder() + .internalName("신") + .name("신안빌") + .roomType("원룸") + .latitude("37.555") + .longitude("126.555") + .floor(1) + .monthlyFee("100") + .charterFee("1000") + .deposit("1000") + .managementFee("100") + .phone("010-1234-5678") + .address("서울시 강남구") + .size("100.0") + .imageUrls(""" + ["https://example1.test.com/image.jpeg", + "https://example2.test.com/image.jpeg"] + """) + .build() + ); + } + + public Land 에듀윌() { + return landRepository.save( + Land.builder() + .internalName("에") + .name("에듀윌") + .roomType("원룸") + .latitude("37.555") + .longitude("126.555") + .floor(1) + .monthlyFee("100") + .charterFee("1000") + .deposit("1000") + .managementFee("100") + .phone("010-1133-5555") + .address("천안시 동남구 강남구") + .size("100.0") + .imageUrls(""" + ["https://example1.test.com/image.jpeg", + "https://example2.test.com/image.jpeg"] + """) + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/LectureFixture.java b/src/test/java/in/koreatech/koin/fixture/LectureFixture.java new file mode 100644 index 000000000..172d9ffd4 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/LectureFixture.java @@ -0,0 +1,97 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetable.repository.LectureRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class LectureFixture { + + private final LectureRepository lectureRepository; + + public LectureFixture(LectureRepository lectureRepository) { + this.lectureRepository = lectureRepository; + } + + public Lecture 건축구조의_이해_및_실습(String semester) { + return lectureRepository.save( + Lecture.builder() + .code("ARB244") + .semester(semester) + .name("건축구조의 이해 및 실습") + .grades("3") + .lectureClass("01") + .regularNumber("25") + .department("디자인ㆍ건축공학부") + .target("디자 1 건축") + .professor("황현식") + .isEnglish("N") + .designScore("0") + .isElearning("N") + .classTime("[200, 201, 202, 203, 204, 205, 206, 207]") + .build() + ); + } + + public Lecture HRD_개론(String semester) { + return lectureRepository.save( + Lecture.builder() + .code("BSM590") + .semester(semester) + .name("컴퓨팅사고") + .grades("3") + .lectureClass("06") + .regularNumber("22") + .department("기계공학부") + .target("기공1") + .professor("박한수,최준호") + .isEnglish("") + .designScore("0") + .isElearning("") + .classTime("[12, 13, 14, 15, 210, 211, 212, 213]") + .build() + ); + } + + public Lecture 재료역학(String semester) { + return lectureRepository.save( + Lecture.builder() + .code("MEB311") + .semester(semester) + .name("재료역학") + .grades("3") + .lectureClass("01") + .regularNumber("35") + .department("기계공학부") + .target("기공전체") + .professor("허준기") + .isEnglish("") + .designScore("0") + .isElearning("") + .classTime("[100, 101, 102, 103, 308, 309]") + .build() + ); + } + + public Lecture 영어청해(String semester) { + return lectureRepository.save( + Lecture.builder() + .code("LAN324") + .semester(semester) + .name("영어청해") + .grades("1") + .lectureClass("09") + .regularNumber("40") + .department("교양학부") + .target("정통2") + .professor("김원경") + .isEnglish("") + .designScore("0") + .isElearning("") + .classTime("[200, 201, 202, 203]") + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/MemberFixture.java b/src/test/java/in/koreatech/koin/fixture/MemberFixture.java new file mode 100644 index 000000000..220f673dc --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/MemberFixture.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.member.model.Member; +import in.koreatech.koin.domain.member.model.Track; +import in.koreatech.koin.domain.member.repository.MemberRepository; +import in.koreatech.koin.domain.member.repository.TrackRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class MemberFixture { + + private final MemberRepository memberRepository; + private final TrackRepository trackRepository; + + public MemberFixture( + MemberRepository memberRepository, + TrackRepository trackRepository + ) { + this.memberRepository = memberRepository; + this.trackRepository = trackRepository; + } + + public Member 최준호(Track track) { + return memberRepository.save( + Member.builder() + .isDeleted(false) + .studentNumber("2019136135") + .imageUrl("https://imagetest.com/juno.jpg") + .name("최준호") + .position("Regular") + .track(track) + .email("testjuno@gmail.com") + .build() + ); + } + + public Member 박한수(Track track) { + return memberRepository.save( + Member.builder() + .isDeleted(false) + .studentNumber("2019136064") + .imageUrl("https://imagetest.com/juno.jpg") + .name("박한수") + .position("Regular") + .track(track) + .email("testhsp@gmail.com") + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/MenuCategoryFixture.java b/src/test/java/in/koreatech/koin/fixture/MenuCategoryFixture.java new file mode 100644 index 000000000..33fe89199 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/MenuCategoryFixture.java @@ -0,0 +1,54 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.repository.MenuCategoryRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class MenuCategoryFixture { + + private final MenuCategoryRepository menuCategoryRepository; + + public MenuCategoryFixture(MenuCategoryRepository menuCategoryRepository) { + this.menuCategoryRepository = menuCategoryRepository; + } + + public MenuCategory 추천메뉴(Shop shop) { + return menuCategoryRepository.save( + MenuCategory.builder() + .shop(shop) + .name("추천메뉴") + .build() + ); + } + + public MenuCategory 사이드메뉴(Shop shop) { + return menuCategoryRepository.save( + MenuCategory.builder() + .shop(shop) + .name("사이드메뉴") + .build() + ); + } + + public MenuCategory 세트메뉴(Shop shop) { + return menuCategoryRepository.save( + MenuCategory.builder() + .shop(shop) + .name("세트메뉴") + .build() + ); + } + + public MenuCategory 메인메뉴(Shop shop) { + return menuCategoryRepository.save( + MenuCategory.builder() + .shop(shop) + .name("메인메뉴") + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/MenuFixture.java b/src/test/java/in/koreatech/koin/fixture/MenuFixture.java new file mode 100644 index 000000000..3b89c474d --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/MenuFixture.java @@ -0,0 +1,114 @@ +package in.koreatech.koin.fixture; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.repository.MenuCategoryMapRepository; +import in.koreatech.koin.domain.shop.repository.MenuRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class MenuFixture { + + private final MenuRepository menuRepository; + private final MenuCategoryMapRepository menuCategoryMapRepository; + + public MenuFixture( + MenuRepository menuRepository, + MenuCategoryMapRepository menuCategoryMapRepository + ) { + this.menuRepository = menuRepository; + this.menuCategoryMapRepository = menuCategoryMapRepository; + } + + public Menu 짜장면_옵션메뉴(Shop shop, MenuCategory menuCategory) { + Menu menu = menuRepository.save( + Menu.builder() + .shopId(shop.getId()) + .name("짜장면") + .description("맛있는 짜장면") + .build() + ); + + menu.getMenuImages().addAll( + List.of( + MenuImage.builder() + .menu(menu) + .imageUrl("https://test.com/짜장면.jpg") + .build(), + MenuImage.builder() + .menu(menu) + .imageUrl("https://test.com/짜장면22.jpg") + .build() + ) + ); + menu.getMenuOptions().addAll( + List.of( + MenuOption.builder() + .menu(menu) + .option("일반") + .price(7000) + .build(), + MenuOption.builder() + .menu(menu) + .option("곱빼기") + .price(7500) + .build() + ) + ); + menu.getMenuCategoryMaps().add( + MenuCategoryMap.builder() + .menu(menu) + .menuCategory(menuCategory) + .build() + ); + + return menuRepository.save(menu); + } + + public Menu 짜장면_단일메뉴(Shop shop, MenuCategory menuCategory) { + + Menu menu = menuRepository.save( + Menu.builder() + .shopId(shop.getId()) + .name("짜장면") + .description("맛있는 짜장면") + .build() + ); + + menu.getMenuImages().addAll( + List.of( + MenuImage.builder() + .menu(menu) + .imageUrl("https://test.com/짜장면.jpg") + .build(), + MenuImage.builder() + .menu(menu) + .imageUrl("https://test.com/짜장면22.jpg") + .build() + ) + ); + menu.getMenuOptions().add( + MenuOption.builder() + .menu(menu) + .option("짜장면") + .price(7000) + .build() + ); + menu.getMenuCategoryMaps().add( + MenuCategoryMap.builder() + .menu(menu) + .menuCategory(menuCategory) + .build() + ); + + return menuRepository.save(menu); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/SemesterFixture.java b/src/test/java/in/koreatech/koin/fixture/SemesterFixture.java new file mode 100644 index 000000000..5533d4bdf --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/SemesterFixture.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetable.repository.SemesterRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class SemesterFixture { + + private final SemesterRepository semesterRepository; + + public SemesterFixture(SemesterRepository semesterRepository) { + this.semesterRepository = semesterRepository; + } + + public Semester semester(String semester) { + return semesterRepository.save( + Semester.builder() + .semester(semester) + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java new file mode 100644 index 000000000..27153f062 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.repository.ShopCategoryRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class ShopCategoryFixture { + + private final ShopCategoryRepository categoryRepository; + + public ShopCategoryFixture(ShopCategoryRepository categoryRepository) { + this.categoryRepository = categoryRepository; + } + + public ShopCategory 카테고리_치킨() { + return categoryRepository.save( + ShopCategory.builder() + .isDeleted(false) + .name("치킨") + .imageUrl("https://test-image.com/ckicken.jpg") + .build() + ); + } + + public ShopCategory 카테고리_일반음식() { + return categoryRepository.save( + ShopCategory.builder() + .isDeleted(false) + .name("일반음식점") + .imageUrl("https://test-image.com/normal.jpg") + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/ShopFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopFixture.java new file mode 100644 index 000000000..2d698f98c --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/ShopFixture.java @@ -0,0 +1,281 @@ +package in.koreatech.koin.fixture; + +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.domain.shop.repository.ShopRepository; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public final class ShopFixture { + + private final ShopRepository shopRepository; + + public ShopFixture(ShopRepository shopRepository) { + this.shopRepository = shopRepository; + } + + public Shop 마슬랜(Owner owner) { + var shop = shopRepository.save( + Shop.builder() + .owner(owner) + .name("마슬랜 치킨") + .internalName("마슬랜") + .chosung("마") + .phone("010-7574-1212") + .address("천안시 동남구 병천면 1600") + .description("마슬랜 치킨입니다.") + .delivery(true) + .deliveryPrice(3000) + .payCard(true) + .payBank(true) + .isDeleted(false) + .isEvent(false) + .remarks("비고") + .hit(0) + .build() + ); + shop.getShopImages().addAll( + List.of( + ShopImage.builder() + .shop(shop) + .imageUrl("https://test-image.com/마슬랜.png") + .build(), + ShopImage.builder() + .shop(shop) + .imageUrl("https://test-image.com/마슬랜2.png") + .build() + ) + ); + shop.getShopOpens().addAll( + List.of( + ShopOpen.builder() + .openTime(LocalTime.of(0, 0)) + .closeTime(LocalTime.of(21, 0)) + .shop(shop) + .closed(false) + .dayOfWeek("MONDAY") + .build(), + ShopOpen.builder() + .openTime(LocalTime.of(0, 0)) + .closeTime(LocalTime.of(0, 0)) + .shop(shop) + .closed(false) + .dayOfWeek("FRIDAY") + .build() + ) + ); + return shopRepository.save(shop); + } + + public Shop 신전_떡볶이(Owner owner) { + var shop = shopRepository.save( + Shop.builder() + .owner(owner) + .name("신전 떡볶이") + .internalName("신전") + .chosung("신") + .phone("010-7788-9900") + .address("천안시 동남구 병천면 1600 신전떡볶이") + .description("신전떡볶이입니다.") + .delivery(true) + .deliveryPrice(2000) + .payCard(true) + .payBank(true) + .isDeleted(false) + .isEvent(false) + .remarks("비고") + .hit(0) + .build() + ); + shop.getShopImages().addAll( + List.of( + ShopImage.builder() + .shop(shop) + .imageUrl("https://test-image.com/신전.png") + .build(), + ShopImage.builder() + .shop(shop) + .imageUrl("https://test-image.com/신전2.png") + .build() + ) + ); + shop.getShopOpens().addAll( + List.of( + ShopOpen.builder() + .openTime(LocalTime.of(0, 0)) + .closeTime(LocalTime.of(21, 0)) + .shop(shop) + .closed(false) + .dayOfWeek("SUNDAY") + .build(), + ShopOpen.builder() + .openTime(LocalTime.of(0, 0)) + .closeTime(LocalTime.of(21, 0)) + .shop(shop) + .closed(false) + .dayOfWeek("FRIDAY") + .build() + ) + ); + return shopRepository.save(shop); + } + + public ShopFixtureBuilder builder() { + return new ShopFixtureBuilder(); + } + + public final class ShopFixtureBuilder { + + private Owner owner; + private String name; + private String internalName; + private String chosung; + private String phone; + private String address; + private String description; + private boolean delivery; + private Integer deliveryPrice; + private boolean payCard; + private boolean payBank; + private boolean isDeleted; + private boolean isEvent; + private String remarks; + private Integer hit; + private List shopCategories = new ArrayList<>(); + private List shopOpens = new ArrayList<>(); + private List shopImages = new ArrayList<>(); + private List menuCategories = new ArrayList<>(); + + public ShopFixtureBuilder owner(Owner owner) { + this.owner = owner; + return this; + } + + public ShopFixtureBuilder name(String name) { + this.name = name; + return this; + } + + public ShopFixtureBuilder internalName(String internalName) { + this.internalName = internalName; + return this; + } + + public ShopFixtureBuilder chosung(String chosung) { + this.chosung = chosung; + return this; + } + + public ShopFixtureBuilder phone(String phone) { + this.phone = phone; + return this; + } + + public ShopFixtureBuilder address(String address) { + this.address = address; + return this; + } + + public ShopFixtureBuilder description(String description) { + this.description = description; + return this; + } + + public ShopFixtureBuilder delivery(boolean delivery) { + this.delivery = delivery; + return this; + } + + public ShopFixtureBuilder deliveryPrice(Integer deliveryPrice) { + this.deliveryPrice = deliveryPrice; + return this; + } + + public ShopFixtureBuilder payCard(boolean payCard) { + this.payCard = payCard; + return this; + } + + public ShopFixtureBuilder payBank(boolean payBank) { + this.payBank = payBank; + return this; + } + + public ShopFixtureBuilder isDeleted(boolean isDeleted) { + this.isDeleted = isDeleted; + return this; + } + + public ShopFixtureBuilder isEvent(boolean isEvent) { + this.isEvent = isEvent; + return this; + } + + public ShopFixtureBuilder remarks(String remarks) { + this.remarks = remarks; + return this; + } + + public ShopFixtureBuilder hit(Integer hit) { + this.hit = hit; + return this; + } + + public ShopFixtureBuilder shopCategories(List shopCategories) { + this.shopCategories = shopCategories; + return this; + } + + public ShopFixtureBuilder shopOpens(List shopOpens) { + this.shopOpens = shopOpens; + return this; + } + + public ShopFixtureBuilder shopImages(List shopImages) { + this.shopImages = shopImages; + return this; + } + + public ShopFixtureBuilder menuCategories(List menuCategories) { + this.menuCategories = menuCategories; + return this; + } + + public Shop build() { + var shop = shopRepository.save( + Shop.builder() + .description(description) + .owner(owner) + .phone(phone) + .address(address) + .payCard(payCard) + .isDeleted(isDeleted) + .isEvent(isEvent) + .delivery(delivery) + .hit(hit) + .internalName(internalName) + .name(name) + .chosung(chosung) + .deliveryPrice(deliveryPrice) + .remarks(remarks) + .payBank(payBank) + .build() + ); + shop.getShopOpens().addAll(shopOpens); + shop.getShopImages().addAll(shopImages); + shop.getMenuCategories().addAll(menuCategories); + shop.getShopCategories().addAll(shopCategories); + return shopRepository.save(shop); + } + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java b/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java new file mode 100644 index 000000000..a8b833531 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.member.model.TechStack; +import in.koreatech.koin.domain.member.model.Track; +import in.koreatech.koin.domain.member.repository.TechStackRepository; + +@Component +public class TechStackFixture { + + private final TechStackRepository techStackRepository; + + public TechStackFixture(TechStackRepository techStackRepository) { + this.techStackRepository = techStackRepository; + } + + public TechStack java(Track track) { + return techStackRepository.save( + TechStack.builder() + .imageUrl("https://testimageurl.com") + .trackId(track.getId()) + .name("Java") + .description("Language") + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/TimeTableFixture.java b/src/test/java/in/koreatech/koin/fixture/TimeTableFixture.java new file mode 100644 index 000000000..b2c94ebf3 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/TimeTableFixture.java @@ -0,0 +1,107 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetable.model.TimeTable; +import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; +import in.koreatech.koin.domain.user.model.User; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class TimeTableFixture { + + private final TimeTableRepository timeTableRepository; + + public TimeTableFixture(TimeTableRepository timeTableRepository) { + this.timeTableRepository = timeTableRepository; + } + + public TimeTable 컴퓨터구조(User user, Semester semester) { + return timeTableRepository.save( + TimeTable.builder() + .user(user) + .semester(semester) + .code("CS101") + .classTitle("컴퓨터 구조") + .classTime("[14, 15, 16, 17, 204, 205, 206, 207]") + .classPlace(null) + .professor("김성재") + .grades("3") + .lectureClass("02") + .target("컴부전체") + .regularNumber("28") + .designScore("0") + .department("컴퓨터공학부") + .memo(null) + .isDeleted(false) + .build() + ); + } + + public TimeTable 운영체제(User user, Semester semester) { + return timeTableRepository.save( + TimeTable.builder() + .user(user) + .semester(semester) + .code("CS102") + .classTitle("운영체제") + .classTime("[932]") + .classPlace(null) + .professor("김원경") + .grades("3") + .lectureClass("01") + .target("컴부전체") + .regularNumber("40") + .designScore("0") + .department("컴퓨터공학부") + .memo(null) + .isDeleted(false) + .build() + ); + } + + public TimeTable 이산수학(User user, Semester semester) { + return timeTableRepository.save( + TimeTable.builder() + .user(user) + .semester(semester) + .code("CSE125") + .classTitle("이산수학") + .classTime("[14, 15, 16, 17, 312, 313]") + .classPlace(null) + .professor("서정빈") + .grades("3") + .lectureClass("01") + .target("컴부전체") + .regularNumber("40") + .designScore("0") + .department("컴퓨터공학부") + .memo(null) + .isDeleted(false) + .build() + ); + } + + public TimeTable 알고리즘및실습(User user, Semester semester) { + return timeTableRepository.save( + TimeTable.builder() + .user(user) + .semester(semester) + .code("CSE130") + .classTitle("알고리즘및실습") + .classTime("[14, 15, 16, 17, 310, 311, 312, 313]") + .classPlace(null) + .professor("박다희") + .grades("3") + .lectureClass("03") + .target("컴부전체") + .regularNumber("32") + .designScore("0") + .department("컴퓨터공학부") + .memo(null) + .isDeleted(false) + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/TrackFixture.java b/src/test/java/in/koreatech/koin/fixture/TrackFixture.java new file mode 100644 index 000000000..5b669dcd9 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/TrackFixture.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.member.model.Track; +import in.koreatech.koin.domain.member.repository.TrackRepository; + +@Component +public class TrackFixture { + + private final TrackRepository trackRepository; + + public TrackFixture(TrackRepository trackRepository) { + this.trackRepository = trackRepository; + } + + public Track backend() { + return trackRepository.save( + Track.builder() + .name("BackEnd") + .build() + ); + } + + public Track frontend() { + return trackRepository.save( + Track.builder() + .name("FrontEnd") + .build() + ); + } + + public Track ios() { + return trackRepository.save( + Track.builder() + .name("iOS") + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/UserFixture.java b/src/test/java/in/koreatech/koin/fixture/UserFixture.java new file mode 100644 index 000000000..2bd45ccff --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/UserFixture.java @@ -0,0 +1,316 @@ +package in.koreatech.koin.fixture; + +import static in.koreatech.koin.domain.user.model.UserGender.MAN; +import static in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; +import static in.koreatech.koin.domain.user.model.UserType.COOP; +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.owner.model.OwnerAttachment; +import in.koreatech.koin.domain.owner.repository.OwnerRepository; +import in.koreatech.koin.domain.user.model.Student; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserGender; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.StudentRepository; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.auth.JwtProvider; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public final class UserFixture { + + private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; + private final OwnerRepository ownerRepository; + private final StudentRepository studentRepository; + private final JwtProvider jwtProvider; + + @Autowired + public UserFixture( + PasswordEncoder passwordEncoder, + UserRepository userRepository, + OwnerRepository ownerRepository, + StudentRepository studentRepository, + JwtProvider jwtProvider + ) { + this.passwordEncoder = passwordEncoder; + this.userRepository = userRepository; + this.ownerRepository = ownerRepository; + this.studentRepository = studentRepository; + this.jwtProvider = jwtProvider; + } + + public Student 준호_학생() { + return studentRepository.save( + Student.builder() + .studentNumber("2019136135") + .anonymousNickname("익명") + .department("컴퓨터공학부") + .userIdentity(UNDERGRADUATE) + .isGraduated(false) + .user( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("준호") + .name("테스트용_준호") + .phoneNumber("010-1234-5678") + .userType(STUDENT) + .gender(MAN) + .email("juno@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build() + ) + .build() + ); + } + + public Student 성빈_학생() { + return studentRepository.save( + Student.builder() + .studentNumber("2023100514") + .anonymousNickname("익명123") + .department("컴퓨터공학부") + .userIdentity(UNDERGRADUATE) + .isGraduated(false) + .user( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("성빈") + .name("테스트용_성빈") + .phoneNumber("010-9941-1123") + .userType(STUDENT) + .gender(MAN) + .email("testsungbeen@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build() + ) + .build() + ); + } + + public Owner 현수_사장님() { + return ownerRepository.save( + Owner.builder() + .companyRegistrationNumber("123-45-67190") + .attachments(List.of( + OwnerAttachment.builder() + .url("https://test.com/현수_사장님_인증사진_1.jpg") + .isDeleted(false) + .build(), + OwnerAttachment.builder() + .url("https://test.com/현수_사장님_인증사진_2.jpg") + .isDeleted(false) + .build() + ) + ) + .grantShop(true) + .grantEvent(true) + .user( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("현수") + .name("테스트용_현수") + .phoneNumber("010-9876-5432") + .userType(OWNER) + .gender(MAN) + .email("hysoo@naver.com") + .isAuthed(true) + .isDeleted(false) + .build() + ) + .build() + ); + } + + public Owner 준영_사장님() { + return ownerRepository.save( + Owner.builder() + .companyRegistrationNumber("112-80-56789") + .attachments(List.of( + OwnerAttachment.builder() + .url("https://test.com/준영_사장님_인증사진_1.jpg") + .isDeleted(false) + .build(), + OwnerAttachment.builder() + .url("https://test.com/준영_사장님_인증사진_2.jpg") + .isDeleted(false) + .build() + ) + ) + .grantShop(true) + .grantEvent(true) + .user( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("준영") + .name("테스트용_준영") + .phoneNumber("010-9776-5112") + .userType(OWNER) + .gender(MAN) + .email("testjoonyoung@gmail.com") + .isAuthed(true) + .isDeleted(false) + .build() + ) + .build() + ); + } + + public User 준기_영양사() { + return userRepository.save( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("준기") + .name("허준기") + .phoneNumber("010-1122-5678") + .userType(COOP) + .gender(MAN) + .email("coop@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build() + ); + } + + public String getToken(User user) { + return jwtProvider.createToken(user); + } + + public UserFixtureBuilder builder() { + return new UserFixtureBuilder(); + } + + public final class UserFixtureBuilder { + + private String password; + private String nickname; + private String name; + private String phoneNumber; + private UserType userType; + private String email; + private UserGender gender; + private boolean isAuthed; + private LocalDateTime lastLoggedAt; + private String profileImageUrl; + private Boolean isDeleted; + private String authToken; + private LocalDateTime authExpiredAt; + private String resetToken; + private LocalDateTime resetExpiredAt; + private String deviceToken; + + public UserFixtureBuilder password(String password) { + this.password = passwordEncoder.encode(password); + return this; + } + + public UserFixtureBuilder nickname(String nickname) { + this.nickname = nickname; + return this; + } + + public UserFixtureBuilder name(String name) { + this.name = name; + return this; + } + + public UserFixtureBuilder phoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + return this; + } + + public UserFixtureBuilder userType(UserType userType) { + this.userType = userType; + return this; + } + + public UserFixtureBuilder email(String email) { + this.email = email; + return this; + } + + public UserFixtureBuilder gender(UserGender gender) { + this.gender = gender; + return this; + } + + public UserFixtureBuilder isAuthed(boolean isAuthed) { + this.isAuthed = isAuthed; + return this; + } + + public UserFixtureBuilder lastLoggedAt(LocalDateTime lastLoggedAt) { + this.lastLoggedAt = lastLoggedAt; + return this; + } + + public UserFixtureBuilder profileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + return this; + } + + public UserFixtureBuilder isDeleted(Boolean isDeleted) { + this.isDeleted = isDeleted; + return this; + } + + public UserFixtureBuilder authToken(String authToken) { + this.authToken = authToken; + return this; + } + + public UserFixtureBuilder authExpiredAt(LocalDateTime authExpiredAt) { + this.authExpiredAt = authExpiredAt; + return this; + } + + public UserFixtureBuilder resetToken(String resetToken) { + this.resetToken = resetToken; + return this; + } + + public UserFixtureBuilder resetExpiredAt(LocalDateTime resetExpiredAt) { + this.resetExpiredAt = resetExpiredAt; + return this; + } + + public UserFixtureBuilder deviceToken(String deviceToken) { + this.deviceToken = deviceToken; + return this; + } + + public User build() { + return userRepository.save( + User.builder() + .phoneNumber(phoneNumber) + .authExpiredAt(authExpiredAt) + .deviceToken(deviceToken) + .lastLoggedAt(lastLoggedAt) + .isAuthed(isAuthed) + .resetExpiredAt(resetExpiredAt) + .resetToken(resetToken) + .nickname(nickname) + .authToken(authToken) + .isDeleted(isDeleted) + .email(email) + .profileImageUrl(profileImageUrl) + .gender(gender) + .password(password) + .userType(userType) + .name(name) + .build() + ); + } + } +} diff --git a/src/test/java/in/koreatech/koin/global/domain/upload/UploadServiceTest.java b/src/test/java/in/koreatech/koin/global/domain/upload/UploadServiceTest.java new file mode 100644 index 000000000..a7e472cd6 --- /dev/null +++ b/src/test/java/in/koreatech/koin/global/domain/upload/UploadServiceTest.java @@ -0,0 +1,91 @@ +package in.koreatech.koin.global.domain.upload; + +import static in.koreatech.koin.global.domain.upload.model.ImageUploadDomain.OWNERS; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.containers.localstack.LocalStackContainer.Service; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.utility.DockerImageName; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.global.domain.upload.dto.UploadUrlRequest; +import in.koreatech.koin.global.domain.upload.service.UploadService; +import in.koreatech.koin.global.s3.S3Utils; +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.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.S3Presigner.Builder; + +@Disabled +class UploadServiceTest extends AcceptanceTest { + + private AmazonS3 s3Client; + private Builder presigner; + + @Container + public static LocalStackContainer localStackContainer = new LocalStackContainer( + DockerImageName.parse("localstack/localstack")) + .withServices(Service.S3); + + @BeforeEach + void setUp() { + AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create( + localStackContainer.getAccessKey(), + localStackContainer.getSecretKey() + ); + s3Client = AmazonS3ClientBuilder.standard() + .withRegion(localStackContainer.getRegion()) + .withCredentials(new AWSStaticCredentialsProvider( + new BasicAWSCredentials( + localStackContainer.getAccessKey(), + localStackContainer.getSecretKey() + )) + ) + .build(); + + presigner = S3Presigner.builder() + .credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)) + .region(Region.of(localStackContainer.getRegion())); + } + + @Test + void 이미지_확장자를_받아_이미지_이름을_UUID로_생성_후_Presigned_URL을_생성하여_반환한다() { + // given + S3Utils utils = new S3Utils( + presigner, + s3Client, + Clock.systemDefaultZone(), + "test-bucket", + "https://test-image.koreatech.in/" + ); + + // when + var request = new UploadUrlRequest( + 1000, + "image/png", + "hello.png" + ); + + UploadService uploadService = new UploadService(utils, Clock.systemDefaultZone()); + var url = uploadService.getPresignedUrl(OWNERS, request); + + // then + assertThat(url.preSignedUrl()).contains( + "https://test-bucket.s3.amazonaws.com/", + "OWNERS/", + "hello.png" + ); + } +} diff --git a/src/test/java/in/koreatech/koin/support/DBInitializer.java b/src/test/java/in/koreatech/koin/support/DBInitializer.java new file mode 100644 index 000000000..fc115ae07 --- /dev/null +++ b/src/test/java/in/koreatech/koin/support/DBInitializer.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.support; + +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +@TestComponent +public class DBInitializer { + + private static final int OFF = 0; + private static final int ON = 1; + private static final int COLUMN_INDEX = 1; + + private final List tableNames = new ArrayList<>(); + + @Autowired + private DataSource dataSource; + + @PersistenceContext + private EntityManager entityManager; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private MongoTemplate mongoTemplate; + + private void findDatabaseTableNames() { + try (final Statement statement = dataSource.getConnection().createStatement()) { + ResultSet resultSet = statement.executeQuery("SHOW TABLES"); + while (resultSet.next()) { + final String tableName = resultSet.getString(COLUMN_INDEX); + tableNames.add(tableName); + } + } catch (Exception ignore) { + } + } + + private void truncate() { + setForeignKeyCheck(OFF); + for (String tableName : tableNames) { + entityManager.createNativeQuery(String.format("TRUNCATE TABLE %s", tableName)).executeUpdate(); + } + setForeignKeyCheck(ON); + } + + private void setForeignKeyCheck(int mode) { + entityManager.createNativeQuery(String.format("SET FOREIGN_KEY_CHECKS = %d", mode)).executeUpdate(); + } + + @Transactional + public void clear() { + if (tableNames.isEmpty()) { + findDatabaseTableNames(); + } + entityManager.clear(); + truncate(); + // Redis 초기화 + redisTemplate.getConnectionFactory().getConnection().flushAll(); + // Mongo 초기화 + for (String collectionName : mongoTemplate.getCollectionNames()) { + mongoTemplate.remove(new Query(), collectionName); + } + } +} diff --git a/src/test/java/in/koreatech/koin/support/JsonAssertions.java b/src/test/java/in/koreatech/koin/support/JsonAssertions.java new file mode 100644 index 000000000..c7ef8b5d7 --- /dev/null +++ b/src/test/java/in/koreatech/koin/support/JsonAssertions.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.support; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.Assertions; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class JsonAssertions { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public static JsonStringAssert assertThat(String expect) { + return new JsonStringAssert(expect); + } + + public static class JsonStringAssert { + + private final String expect; + + JsonStringAssert(String expect) { + this.expect = expect; + } + + public void isEqualTo(String actual) { + try { + Object responseObj = parseJson(expect); + Object expectedObj = parseJson(actual); + + Assertions.assertThat(responseObj).isEqualTo(expectedObj); + } catch (Exception e) { + throw new AssertionError("json parsing error\n" + e.getMessage()); + } + } + + private Object parseJson(String json) throws IOException { + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (IOException e) { + return objectMapper.readValue(json, new TypeReference>() { + }); + } + } + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 000000000..2c1fb406f --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,72 @@ +jwt: + secret-key: EXAMPLE7A3E4F37B3DAD9CD8KEY6AA4B1AF7123!@# + access-token: + expiration-time: 600000 # (ms) = 10 minutes + +spring: + flyway: + enabled: false + jpa: + properties: + hibernate: + show_sql: true + format_sql: true + hibernate: + ddl-auto: create + thymeleaf: + prefix: "classpath:/mail/" + suffix: ".html" + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + +server: + tomcat: + max-http-form-post-size: 10MB + +logging: + level: + org: + hibernate: + type: + descriptor: + sql: trace + +swagger: + server-url: http://localhost:8080 + +slack: + koin_event_notify_url: https://slack-weehookurl.com + koin_owner_event_notify_url: https://slack-weehookurl.com + logging: + error: https://slack-weehookurl.com + +aws: + ses: + access-key: testck + secret-key: testsk + +s3: + key: test-ck + secret: test-sk + bucket: test-bucket + custom_domain: https://test.koreatech.in/ + +koin: + admin: + url: https://admin-url-path.com + +OPEN_API_KEY: test + +cors: + allowedOrigins: + - http://localhost:3000 + +naver: + accessKey: testck + secretKey: testsk + sms: + apiUrl: http://localhost:8888 + serviceId: ncp:sms:kr:test + fromNumber: "01012331234" diff --git a/src/test/resources/koin-firebase-adminsdk.json b/src/test/resources/koin-firebase-adminsdk.json new file mode 100644 index 000000000..6d48b460d --- /dev/null +++ b/src/test/resources/koin-firebase-adminsdk.json @@ -0,0 +1,13 @@ +{ + "type": "test", + "project_id": "test", + "private_key_id": "test", + "private_key": "-----test", + "client_email": "test", + "client_id": "test", + "auth_uri": "test", + "token_uri": "test", + "auth_provider_x509_cert_url": "test", + "client_x509_cert_url": "test", + "universe_domain": "testm" +}