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 index c13d74fa8..ad97209ad 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java @@ -4,6 +4,7 @@ import java.util.List; import org.springframework.format.annotation.DateTimeFormat; +import in.koreatech.koin.domain.bus.dto.*; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -139,4 +140,14 @@ ResponseEntity getBusRouteSchedule( @RequestParam BusStation depart, @RequestParam BusStation arrival ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "버스 긴급 공지 조회") + @GetMapping("/notice") + ResponseEntity getNotice(); } 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 index bfa8bd567..56399fa81 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java @@ -1,26 +1,6 @@ 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.PathVariable; -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.BusRouteCommand; -import in.koreatech.koin.domain.bus.dto.BusScheduleResponse; -import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; -import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; -import in.koreatech.koin.domain.bus.dto.ShuttleBusRoutesResponse; -import in.koreatech.koin.domain.bus.dto.ShuttleBusTimetableResponse; -import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; +import in.koreatech.koin.domain.bus.dto.*; import in.koreatech.koin.domain.bus.model.BusTimetable; import in.koreatech.koin.domain.bus.model.enums.BusRouteType; import in.koreatech.koin.domain.bus.model.enums.BusStation; @@ -28,7 +8,15 @@ import in.koreatech.koin.domain.bus.model.enums.CityBusDirection; import in.koreatech.koin.domain.bus.service.BusService; import in.koreatech.koin.domain.bus.service.ShuttleBusService; +import in.koreatech.koin.domain.community.article.service.ArticleService; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; @RestController @RequiredArgsConstructor @@ -113,4 +101,10 @@ public ResponseEntity getBusRouteSchedule( BusScheduleResponse busSchedule = busService.getBusSchedule(request); return ResponseEntity.ok().body(busSchedule); } + + @GetMapping("/notice") + public ResponseEntity getNotice() { + BusNoticeResponse busNoticeResponse = busService.getNotice(); + return ResponseEntity.ok().body(busNoticeResponse); + } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/BusNoticeResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/BusNoticeResponse.java new file mode 100644 index 000000000..8c8b2dad5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/BusNoticeResponse.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.bus.dto; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record BusNoticeResponse( + @Schema(description = "공지글 번호", example = "17153", requiredMode = NOT_REQUIRED) + Integer id, + + @Schema(description = "공지글 제목", example = "[긴급][총무팀]2024.11.27.(수, 오늘) 천안, 청주 야간 셔틀 20시 지연운행", requiredMode = NOT_REQUIRED) + String title +) { + + public static BusNoticeResponse of(Integer id, String title) { + return new BusNoticeResponse(id, title); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusSchedule.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusSchedule.java index dd796bce9..74f5ce09f 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusSchedule.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusSchedule.java @@ -47,11 +47,11 @@ public final class ExpressBusSchedule { LocalTime.of(22, 5) ); - public static List getExpressBusScheduleToKoreaTech() { + public static List TerminalToKoreaTech() { return KOREA_TECH_SCHEDULE; } - public static List getExpressBusScheduleToTerminal() { + public static List KoreaTechToTerminal() { return TERMINAL_SCHEDULE; } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/BusNoticeRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/BusNoticeRepository.java new file mode 100644 index 000000000..dbf38e63b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/BusNoticeRepository.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.bus.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Map; + +@Repository +@RequiredArgsConstructor +public class BusNoticeRepository { + + public static final String BUS_NOTICE_KEY = "busNoticeArticle"; + private final RedisTemplate redisTemplate; + + public Map getBusNotice() { + Map article = redisTemplate.opsForHash().entries(BUS_NOTICE_KEY); + + if (article.isEmpty()) { + return null; + } + + return article; + } +} 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 index 0bad5142f..f080681a5 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java +++ b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java @@ -1,31 +1,7 @@ package in.koreatech.koin.domain.bus.service; -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.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Locale; -import java.util.Set; - -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.BusRouteCommand; -import in.koreatech.koin.domain.bus.dto.BusScheduleResponse; +import in.koreatech.koin.domain.bus.dto.*; import in.koreatech.koin.domain.bus.dto.BusScheduleResponse.ScheduleInfo; -import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; -import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; -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; @@ -39,6 +15,7 @@ import in.koreatech.koin.domain.bus.model.mongo.BusCourse; import in.koreatech.koin.domain.bus.model.mongo.CityBusTimetable; import in.koreatech.koin.domain.bus.model.mongo.Route; +import in.koreatech.koin.domain.bus.repository.BusNoticeRepository; import in.koreatech.koin.domain.bus.repository.BusRepository; import in.koreatech.koin.domain.bus.repository.CityBusTimetableRepository; import in.koreatech.koin.domain.bus.service.route.BusRouteStrategy; @@ -49,6 +26,14 @@ import in.koreatech.koin.domain.version.service.VersionService; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.time.format.TextStyle; +import java.util.*; + +import static in.koreatech.koin.domain.bus.model.enums.BusStation.getDirection; @Service @Transactional(readOnly = true) @@ -57,6 +42,7 @@ public class BusService { private final Clock clock; private final BusRepository busRepository; + private final BusNoticeRepository busNoticeRepository; private final CityBusTimetableRepository cityBusTimetableRepository; private final CityBusClient cityBusClient; private final ExpressBusService expressBusService; @@ -255,4 +241,17 @@ public BusScheduleResponse getBusSchedule(BusRouteCommand request) { scheduleInfoList ); } + + public BusNoticeResponse getNotice() { + Map article = busNoticeRepository.getBusNotice(); + + if (article == null || article.isEmpty()) { + return null; + } + + return BusNoticeResponse.of( + (Integer) article.get("id"), + (String) article.get("title") + ); + } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/route/ExpressBusRouteStrategy.java b/src/main/java/in/koreatech/koin/domain/bus/service/route/ExpressBusRouteStrategy.java index 8e66519fe..9ca58b43f 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/service/route/ExpressBusRouteStrategy.java +++ b/src/main/java/in/koreatech/koin/domain/bus/service/route/ExpressBusRouteStrategy.java @@ -21,7 +21,7 @@ public class ExpressBusRouteStrategy implements BusRouteStrategy { @Override public List findSchedule(BusRouteCommand command) { - if(validCourse(command.depart(), command.arrive())) return Collections.emptyList(); + if(validRoute(command.depart(), command.arrive())) return Collections.emptyList(); BusDirection direction = getRouteDirection(command.depart(), command.arrive()); return getStaticExpressBusScheduleTimeList(direction).stream() @@ -36,8 +36,8 @@ public boolean support(BusRouteType type) { private List getStaticExpressBusScheduleTimeList(BusDirection direction) { return switch (direction) { - case NORTH -> ExpressBusSchedule.getExpressBusScheduleToKoreaTech(); - case SOUTH -> ExpressBusSchedule.getExpressBusScheduleToTerminal(); + case NORTH -> ExpressBusSchedule.KoreaTechToTerminal(); + case SOUTH -> ExpressBusSchedule.TerminalToKoreaTech(); }; } @@ -46,7 +46,7 @@ private BusDirection getRouteDirection(BusStation depart, BusStation arrive) { ? BusDirection.NORTH : BusDirection.SOUTH; } - private boolean validCourse(BusStation depart, BusStation arrive) { + private boolean validRoute(BusStation depart, BusStation arrive) { return (depart == BusStation.STATION || arrive == BusStation.STATION); } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java b/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java new file mode 100644 index 000000000..4c3e062d5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.domain.community.article.model.redis; + +import in.koreatech.koin.domain.community.article.model.Article; +import org.springframework.data.annotation.Id; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.redis.core.RedisHash; + +@Getter +@RedisHash(value = "busNoticeArticle") +public class BusNoticeArticle { + + @Id + private Integer id; + + private String title; + + @Builder + private BusNoticeArticle(Integer id, String title) { + this.id = id; + this.title = title; + } + + public static BusNoticeArticle from(Article article) { + return BusNoticeArticle.builder() + .id(article.getId()) + .title(article.getTitle()) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java index 339d4b78e..242095306 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java @@ -120,4 +120,9 @@ default Article getNextArticle(Board board, Article article) { @Query("SELECT a.title FROM Article a WHERE a.id = :id") String getTitleById(@Param("id") Integer id); + + @Query(value = "SELECT * FROM new_articles a " + + "WHERE a.title REGEXP '통학버스|등교버스|셔틀버스|하교버스' AND a.is_notice = true " + + "ORDER BY a.created_at DESC LIMIT 5", nativeQuery = true) + List
findBusArticlesTop5OrderByCreatedAtDesc(); } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/redis/BusArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/redis/BusArticleRepository.java new file mode 100644 index 000000000..8396ec6b1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/redis/BusArticleRepository.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.domain.community.article.repository.redis; + +import in.koreatech.koin.domain.community.article.model.redis.BusNoticeArticle; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Map; + +@Repository +@RequiredArgsConstructor +public class BusArticleRepository { + + public static final String BUS_NOTICE_KEY = "busNoticeArticle"; + private final RedisTemplate redisTemplate; + + public void save(BusNoticeArticle article) { + Map existingArticle = redisTemplate.opsForHash().entries(BUS_NOTICE_KEY); + + if (!existingArticle.isEmpty()) { + Object existingId = existingArticle.get("id"); + if (existingId.equals(article.getId())) { + return; + } + } + + redisTemplate.delete(BUS_NOTICE_KEY); + redisTemplate.opsForHash().put(BUS_NOTICE_KEY, "id", article.getId()); + redisTemplate.opsForHash().put(BUS_NOTICE_KEY, "title", article.getTitle()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/scheduler/ArticleScheduler.java b/src/main/java/in/koreatech/koin/domain/community/article/scheduler/ArticleScheduler.java index 579172cd3..1c481a234 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/scheduler/ArticleScheduler.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/scheduler/ArticleScheduler.java @@ -31,4 +31,13 @@ public void resetOldKeywordsAndIpMaps() { log.error("많이 검색한 키워드 초기화 중에 오류가 발생했습니다.", e); } } + + @Scheduled(cron = "0 0 * * * *") + public void getBusNoticeArticle() { + try { + articleService.updateBusNoticeArticle(); + } catch (Exception e) { + log.error("버스 공지 게시글 조회 중에 오류가 발생했습니다.", e); + } + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java index a98721dfb..198769340 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java @@ -1,22 +1,5 @@ package in.koreatech.koin.domain.community.article.service; -import java.time.Clock; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import in.koreatech.koin.domain.community.article.dto.ArticleHotKeywordResponse; import in.koreatech.koin.domain.community.article.dto.ArticleResponse; import in.koreatech.koin.domain.community.article.dto.ArticlesResponse; @@ -28,17 +11,31 @@ import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.article.model.redis.ArticleHit; import in.koreatech.koin.domain.community.article.model.redis.ArticleHitUser; +import in.koreatech.koin.domain.community.article.model.redis.BusNoticeArticle; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; import in.koreatech.koin.domain.community.article.repository.ArticleSearchKeywordIpMapRepository; import in.koreatech.koin.domain.community.article.repository.ArticleSearchKeywordRepository; import in.koreatech.koin.domain.community.article.repository.BoardRepository; import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitRepository; import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitUserRepository; +import in.koreatech.koin.domain.community.article.repository.redis.BusArticleRepository; import in.koreatech.koin.domain.community.article.repository.redis.HotArticleRepository; import in.koreatech.koin.global.concurrent.ConcurrencyGuard; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import in.koreatech.koin.global.model.Criteria; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -64,6 +61,7 @@ public class ArticleService { private final HotArticleRepository hotArticleRepository; private final ArticleHitUserRepository articleHitUserRepository; private final Clock clock; + private final BusArticleRepository busArticleRepository; @Transactional public ArticleResponse getArticle(Integer boardId, Integer articleId, String publicIp) { @@ -259,4 +257,32 @@ public void updateHotArticles() { } hotArticleRepository.saveArticlesWithHitToRedis(articlesIdWithHit, HOT_ARTICLE_LIMIT); } + + @Transactional + public void updateBusNoticeArticle() { + List
articles = articleRepository.findBusArticlesTop5OrderByCreatedAtDesc(); + LocalDate latestDate = articles.get(0).getCreatedAt().toLocalDate(); + List
latestArticles = articles.stream() + .filter(article -> article.getCreatedAt().toLocalDate().isEqual(latestDate)) + .toList(); + + if (latestArticles.size() >= 2) { + latestArticles = latestArticles.stream() + .sorted((first, second) -> { + int firstWeight = 0; + int secondWeight = 0; + + // 제목(title)에 "사과"가 들어가면 후순위, "긴급"이 포함되면 우선순위 + if (first.getTitle().contains("사과")) firstWeight++; + if (first.getTitle().contains("긴급")) firstWeight--; + + if (second.getTitle().contains("사과")) secondWeight++; + if (second.getTitle().contains("긴급")) secondWeight--; + + return Integer.compare(firstWeight, secondWeight); + }) + .toList(); + } + busArticleRepository.save(BusNoticeArticle.from(latestArticles.get(0))); + } } 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 index a21a27627..455b71023 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java +++ b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java @@ -48,7 +48,6 @@ ResponseEntity changeSoldOut( ); @ApiResponses( - value = { @ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), diff --git a/src/main/java/in/koreatech/koin/domain/coop/exception/DuplicateExcelRequestException.java b/src/main/java/in/koreatech/koin/domain/coop/exception/DuplicateExcelRequestException.java new file mode 100644 index 000000000..2fd98dd62 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/exception/DuplicateExcelRequestException.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.coop.exception; + +import java.time.LocalDate; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class DuplicateExcelRequestException extends DuplicationException { + + private static final String DEFAULT_MESSAGE = "동일한 요청을 30초 안에 다시 보낼 수 없습니다!"; + + public DuplicateExcelRequestException(String message) { + super(message); + } + + public DuplicateExcelRequestException(String message, String detail) { + super(message, detail); + } + + public static DuplicateExcelRequestException withDetail(LocalDate startDate, LocalDate endDate) { + return new DuplicateExcelRequestException(DEFAULT_MESSAGE, + "startDate: '" + startDate + "'" + "endDate: " + endDate); + } +} \ No newline at end of file diff --git a/src/main/java/in/koreatech/koin/domain/coop/model/DiningNotifyCache.java b/src/main/java/in/koreatech/koin/domain/coop/model/DiningNotifyCache.java new file mode 100644 index 000000000..7f365f557 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/model/DiningNotifyCache.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.domain.coop.model; + +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("DiningNotify") +public class DiningNotifyCache { + + private static final long CACHE_EXPIRE_HOUR_BY_COOP = 3L; + + @Id + private String id; + + @TimeToLive(unit = TimeUnit.HOURS) + private final Long expiration; + + @Builder + private DiningNotifyCache(String id, Long expiration){ + this.id = id; + this.expiration = CACHE_EXPIRE_HOUR_BY_COOP; + } + + public static DiningNotifyCache from(String diningId){ + return DiningNotifyCache.builder() + .id(diningId) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/model/ExcelDownloadCache.java b/src/main/java/in/koreatech/koin/domain/coop/model/ExcelDownloadCache.java new file mode 100644 index 000000000..c37d131d4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/model/ExcelDownloadCache.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.domain.coop.model; + +import java.time.LocalDate; +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 = "excelDownload") +public class ExcelDownloadCache { + + private static final long CACHE_EXPIRE_SECONDS = 30L; + + @Id + private String id; + + @TimeToLive(unit = TimeUnit.SECONDS) + private final Long expiration; + + @Builder + private ExcelDownloadCache(String id, Long expiration) { + this.id = id; + this.expiration = expiration; + } + + public static ExcelDownloadCache from(LocalDate startDate, LocalDate endDate) { + return ExcelDownloadCache.builder() + .id(startDate.toString() + endDate.toString()) + .expiration(CACHE_EXPIRE_SECONDS) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/repository/DiningNotifyCacheRepository.java b/src/main/java/in/koreatech/koin/domain/coop/repository/DiningNotifyCacheRepository.java new file mode 100644 index 000000000..e89db50e2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/repository/DiningNotifyCacheRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.coop.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.coop.exception.DiningCacheNotFoundException; +import in.koreatech.koin.domain.coop.model.DiningNotifyCache; + +public interface DiningNotifyCacheRepository extends Repository { + + DiningNotifyCache save(DiningNotifyCache diningNotifyCache); + + boolean existsById(String diningNotifyId); + + Optional findById(String diningPlace); + + default DiningNotifyCache getById(String diningPlace) { + return findById(diningPlace).orElseThrow( + () -> DiningCacheNotFoundException.withDetail("diningSoldOutCache: " + diningPlace)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/repository/ExcelDownloadCacheRepository.java b/src/main/java/in/koreatech/koin/domain/coop/repository/ExcelDownloadCacheRepository.java new file mode 100644 index 000000000..29500b882 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/repository/ExcelDownloadCacheRepository.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.coop.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.coop.model.ExcelDownloadCache; + +public interface ExcelDownloadCacheRepository extends Repository { + + ExcelDownloadCache save(ExcelDownloadCache excelDownloadCache); + + Optional findById(String id); + + boolean existsById(String id); +} 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 index 9e72a3b09..406582f17 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java +++ b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java @@ -35,12 +35,16 @@ import in.koreatech.koin.domain.coop.dto.SoldOutRequest; import in.koreatech.koin.domain.coop.exception.DiningLimitDateException; import in.koreatech.koin.domain.coop.exception.DiningNowDateException; +import in.koreatech.koin.domain.coop.exception.DuplicateExcelRequestException; import in.koreatech.koin.domain.coop.exception.StartDateAfterEndDateException; import in.koreatech.koin.domain.coop.model.Coop; import in.koreatech.koin.domain.coop.model.DiningImageUploadEvent; import in.koreatech.koin.domain.coop.model.DiningSoldOutEvent; +import in.koreatech.koin.domain.coop.model.ExcelDownloadCache; import in.koreatech.koin.domain.coop.repository.CoopRepository; +import in.koreatech.koin.domain.coop.repository.DiningNotifyCacheRepository; import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; +import in.koreatech.koin.domain.coop.repository.ExcelDownloadCacheRepository; import in.koreatech.koin.domain.coopshop.model.CoopShopType; import in.koreatech.koin.domain.coopshop.service.CoopShopService; import in.koreatech.koin.domain.dining.model.Dining; @@ -61,11 +65,14 @@ public class CoopService { private final ApplicationEventPublisher eventPublisher; private final DiningRepository diningRepository; private final DiningSoldOutCacheRepository diningSoldOutCacheRepository; + private final ExcelDownloadCacheRepository excelDownloadCacheRepository; + private final DiningNotifyCacheRepository diningNotifyCacheRepository; private final CoopRepository coopRepository; private final UserTokenRepository userTokenRepository; private final CoopShopService coopShopService; private final PasswordEncoder passwordEncoder; private final JwtProvider jwtProvider; + private final List placeFilters = Arrays.asList("A코너", "B코너", "C코너"); public static final LocalDate LIMIT_DATE = LocalDate.of(2022, 11, 29); private final int EXCEL_COLUMN_COUNT = 8; @@ -90,17 +97,68 @@ public void changeSoldOut(SoldOutRequest soldOutRequest) { @Transactional public void saveDiningImage(DiningImageRequest imageRequest) { Dining dining = diningRepository.getById(imageRequest.menuId()); + boolean isImageExist = diningRepository.existsByDateAndTypeAndImageUrlIsNotNull(dining.getDate(), dining.getType()); - LocalDateTime now = LocalDateTime.now(clock); boolean isOpened = coopShopService.getIsOpened(now, CoopShopType.CAFETERIA, dining.getType(), true); if (isOpened && !isImageExist) { eventPublisher.publishEvent(new DiningImageUploadEvent(dining.getId(), dining.getImageUrl())); } + dining.setImageUrl(imageRequest.imageUrl()); } + /* TODO: 알림 로직 테스트 후 주석 제거 + public void sendDiningNotify() { + DiningType diningType = coopShopService.getDiningType(); + LocalDate nowDate = LocalDate.now(clock); + List dinings = diningRepository.findAllByDateAndType(nowDate, diningType); + + if (dinings.isEmpty()) { + return; + } + + boolean allImageExist = diningRepository.allExistsByDateAndTypeAndPlacesAndImageUrlIsNotNull( + nowDate, diningType, placeFilters + ); + + boolean isOpened = coopShopService.getIsOpened( + LocalDateTime.now(clock), CoopShopType.CAFETERIA, diningType, true + ); + + String diningNotifyId = nowDate.toString() + diningType; + + if (isOpened && allImageExist) { + if (alreadyNotify(diningNotifyId)) + return; + + if (!diningNotifyCacheRepository.existsById(diningNotifyId)) { + sendNotify(diningNotifyId, dinings); + } + } + + if (LocalTime.now().isAfter(diningType.getStartTime().minusMinutes(10)) + && LocalTime.now().isBefore(diningType.getStartTime()) + && !diningNotifyCacheRepository.existsById(diningNotifyId) + && diningRepository.existsByDateAndTypeAndImageUrlIsNotNull(nowDate, diningType) + ) { + sendNotify(diningNotifyId, dinings); + } + } + + private boolean alreadyNotify(String diningNotifyId) { + if (diningNotifyCacheRepository.existsById(diningNotifyId)) { + return true; + } + return false; + } + + private void sendNotify(String diningNotifyId, List dinings) { + diningNotifyCacheRepository.save(DiningNotifyCache.from(diningNotifyId)); + eventPublisher.publishEvent(new DiningImageUploadEvent(dinings.get(0).getId(), dinings.get(0).getImageUrl())); + }*/ + @Transactional public CoopLoginResponse coopLogin(CoopLoginRequest request) { Coop coop = coopRepository.getByCoopId(request.id()); @@ -119,6 +177,7 @@ public CoopLoginResponse coopLogin(CoopLoginRequest request) { } public ByteArrayInputStream generateDiningExcel(LocalDate startDate, LocalDate endDate, Boolean isCafeteria) { + checkDuplicateExcelRequest(startDate, endDate); validateDates(startDate, endDate); List dinings = fetchDiningData(startDate, endDate, isCafeteria); @@ -217,7 +276,7 @@ private void fillDiningRow(Dining dining, Row row, CellStyle commonStyle) { row.createCell(6).setCellValue(Optional.ofNullable(dining.getSoldOut()).map(Object::toString).orElse("")); row.createCell(7).setCellValue(Optional.ofNullable(dining.getIsChanged()).map(Object::toString).orElse("")); - for (int i = 0; i < 8; i++) { + for (int i = 0; i < EXCEL_COLUMN_COUNT; i++) { row.getCell(i).setCellStyle(commonStyle); } } @@ -233,4 +292,13 @@ private ByteArrayInputStream writeWorkbookToStream(SXSSFWorkbook workbook) throw return new ByteArrayInputStream(out.toByteArray()); } } + + private void checkDuplicateExcelRequest(LocalDate startDate, LocalDate endDate) { + boolean isCacheExist = excelDownloadCacheRepository.existsById(startDate.toString() + endDate.toString()); + + if (isCacheExist) { + throw DuplicateExcelRequestException.withDetail(startDate, endDate); + } + excelDownloadCacheRepository.save(ExcelDownloadCache.from(startDate, endDate)); + } } diff --git a/src/main/java/in/koreatech/koin/domain/coop/util/CoopScheduler.java b/src/main/java/in/koreatech/koin/domain/coop/util/CoopScheduler.java new file mode 100644 index 000000000..7c0983dee --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/util/CoopScheduler.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.coop.util; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.coop.service.CoopService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CoopScheduler { + + private final CoopService coopService; + + /* TODO: 알림 로직 테스트 후 주석 제거 + @Scheduled(cron = "0 0/6 7 * * *") + @Scheduled(cron = "0 30/6 10 * * *") + @Scheduled(cron = "0 0/6 11 * * *") + @Scheduled(cron = "0 30/6 16 * * *") + @Scheduled(cron = "0 0/6 17 * * *") + public void notifyDiningImageUpload() { + try { + coopService.sendDiningNotify(); + } catch (Exception e) { + log.warn("식단 이미지 알림 과정에서 오류가 발생했습니다."); + } + }*/ +} diff --git a/src/main/java/in/koreatech/koin/domain/coopshop/exception/DiningTypeNotFoundException.java b/src/main/java/in/koreatech/koin/domain/coopshop/exception/DiningTypeNotFoundException.java new file mode 100644 index 000000000..0a29a4ec1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coopshop/exception/DiningTypeNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.coopshop.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/coopshop/service/CoopShopService.java b/src/main/java/in/koreatech/koin/domain/coopshop/service/CoopShopService.java index 6e3414569..55858ccff 100644 --- a/src/main/java/in/koreatech/koin/domain/coopshop/service/CoopShopService.java +++ b/src/main/java/in/koreatech/koin/domain/coopshop/service/CoopShopService.java @@ -1,5 +1,7 @@ package in.koreatech.koin.domain.coopshop.service; +import static in.koreatech.koin.domain.dining.model.DiningType.*; + import java.time.Clock; import java.time.DayOfWeek; import java.time.LocalDate; @@ -14,14 +16,15 @@ import in.koreatech.koin.domain.coopshop.dto.CoopShopResponse; import in.koreatech.koin.domain.coopshop.dto.CoopShopsResponse; import in.koreatech.koin.domain.coopshop.exception.CoopSemesterNotFoundException; +import in.koreatech.koin.domain.coopshop.exception.DiningTypeNotFoundException; import in.koreatech.koin.domain.coopshop.model.CoopOpen; import in.koreatech.koin.domain.coopshop.model.CoopSemester; import in.koreatech.koin.domain.coopshop.model.CoopShop; import in.koreatech.koin.domain.coopshop.model.CoopShopType; import in.koreatech.koin.domain.coopshop.model.DayType; import in.koreatech.koin.domain.coopshop.repository.CoopOpenRepository; -import in.koreatech.koin.domain.coopshop.repository.CoopShopRepository; import in.koreatech.koin.domain.coopshop.repository.CoopSemesterRepository; +import in.koreatech.koin.domain.coopshop.repository.CoopShopRepository; import in.koreatech.koin.domain.dining.model.DiningType; import lombok.RequiredArgsConstructor; @@ -71,6 +74,23 @@ public boolean getIsOpened(LocalDateTime now, CoopShopType coopShopType, DiningT } } + public DiningType getDiningType(){ + if(LocalTime.now(clock).isAfter(BREAKFAST.getStartTime().minusHours(1)) + && LocalTime.now(clock).isBefore(BREAKFAST.getEndTime())){ + return BREAKFAST; + } + if(LocalTime.now(clock).isAfter(LUNCH.getStartTime().minusHours(1)) + && LocalTime.now(clock).isBefore(LUNCH.getEndTime())){ + return LUNCH; + } + if(LocalTime.now(clock).isAfter(DINNER.getStartTime().minusHours(1)) + && LocalTime.now(clock).isBefore(DINNER.getEndTime())){ + return DINNER; + } + + throw DiningTypeNotFoundException.withDetail(LocalTime.now() + ""); + } + @Transactional public void updateSemester() { CoopSemester currentSemester = coopSemesterRepository.getByIsApplied(true); 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 index c43547c07..e525a3d33 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningRepository.java +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.coop.exception.MenuNotFoundException; @@ -36,4 +37,15 @@ default Dining getById(Integer id) { List findByDateBetween(LocalDate startDate, LocalDate endDate); List findByDateBetweenAndPlaceIn(LocalDate startDate, LocalDate endDate, List placeFilters); + + Optional> findByDate(LocalDate now); + + default List getByDate(LocalDate now){ + return findByDate(now) + .orElseThrow(()-> MenuNotFoundException.withDetail("menuId: " + now)); + } + + @Query("SELECT COUNT(d) = (SELECT COUNT(d2) FROM Dining d2 WHERE d2.date = :date AND d2.type = :type AND d2.place IN :places) " + + "FROM Dining d WHERE d.date = :date AND d.type = :type AND d.place IN :places AND d.imageUrl IS NOT NULL") + boolean allExistsByDateAndTypeAndPlacesAndImageUrlIsNotNull(LocalDate date, DiningType type, List places); } diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java index 176bd4f3e..4715310f7 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java @@ -60,21 +60,22 @@ default TimetableFrame getByUser(User user) { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query( """ - SELECT t FROM TimetableFrame t - WHERE t.user.id = :userId - AND t.semester.id = :semesterId - AND t.isMain = false - ORDER BY t.createdAt ASC - LIMIT 1 - """) - TimetableFrame findNextFirstTimetableFrame(@Param("userId") Integer userId, @Param("semesterId") Integer semesterId); + SELECT t FROM TimetableFrame t + WHERE t.user.id = :userId + AND t.semester.id = :semesterId + AND t.isMain = false + ORDER BY t.createdAt ASC + LIMIT 1 + """) + TimetableFrame findNextFirstTimetableFrame(@Param("userId") Integer userId, + @Param("semesterId") Integer semesterId); @Query( """ - SELECT COUNT(t) FROM TimetableFrame t - WHERE t.user.id = :userId - AND t.semester.id = :semesterId - """) + SELECT COUNT(t) FROM TimetableFrame t + WHERE t.user.id = :userId + AND t.semester.id = :semesterId + """) int countByUserIdAndSemesterId(@Param("userId") Integer userId, @Param("semesterId") Integer semesterId); void deleteById(Integer id); @@ -90,7 +91,10 @@ default TimetableFrame getByIdWithDeleted(Integer id) { return findByIdWithDeleted(id) .orElseThrow(() -> TimetableFrameNotFoundException.withDetail("id: " + id)); } + void deleteAllByUserAndSemester(User user, Semester semester); List findAllByUserId(Integer userId); + + boolean existsByUserAndSemester(User user, Semester semester); } diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableLectureService.java b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableLectureService.java index b1120cc08..e15c34d8c 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableLectureService.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableLectureService.java @@ -12,12 +12,14 @@ import in.koreatech.koin.domain.timetableV2.dto.request.TimetableLectureCreateRequest; import in.koreatech.koin.domain.timetableV2.dto.request.TimetableLectureUpdateRequest; import in.koreatech.koin.domain.timetableV2.dto.response.TimetableLectureResponse; +import in.koreatech.koin.domain.timetableV2.factory.TimetableLectureCreator; +import in.koreatech.koin.domain.timetableV2.factory.TimetableLectureUpdater; import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; -import in.koreatech.koin.domain.timetableV2.factory.TimetableLectureCreator; -import in.koreatech.koin.domain.timetableV2.factory.TimetableLectureUpdater; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import lombok.RequiredArgsConstructor; @@ -29,6 +31,7 @@ public class TimetableLectureService { @PersistenceContext private EntityManager entityManager; + private final UserRepository userRepository; private final TimetableLectureRepositoryV2 timetableLectureRepositoryV2; private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; private final TimetableLectureCreator timetableLectureCreator; @@ -101,6 +104,14 @@ public TimetableLectureResponse rollbackTimetableLecture(List timetable public TimetableLectureResponse rollbackTimetableFrame(Integer frameId, Integer userId) { TimetableFrame timetableFrame = timetableFrameRepositoryV2.getByIdWithDeleted(frameId); validateUserAuthorization(timetableFrame.getUser().getId(), userId); + + User user = userRepository.getById(userId); + boolean hasTimetableFrame = timetableFrameRepositoryV2.existsByUserAndSemester(user, + timetableFrame.getSemester()); + + if (!hasTimetableFrame) { + timetableFrame.updateMainFlag(true); + } timetableFrame.undelete(); timetableLectureRepositoryV2.findAllByFrameIdWithDeleted(timetableFrame.getId()).stream() diff --git a/src/main/java/in/koreatech/koin/global/config/RedisConfig.java b/src/main/java/in/koreatech/koin/global/config/RedisConfig.java index 8f58e0565..74bb68cba 100644 --- a/src/main/java/in/koreatech/koin/global/config/RedisConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/RedisConfig.java @@ -34,6 +34,8 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setConnectionFactory(connectionFactory); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(serializer); return template; } diff --git a/src/main/resources/db/migration/V105__alter_lecture_semester_date_size.sql b/src/main/resources/db/migration/V105__alter_lecture_semester_date_size.sql new file mode 100644 index 000000000..620ec97ae --- /dev/null +++ b/src/main/resources/db/migration/V105__alter_lecture_semester_date_size.sql @@ -0,0 +1 @@ +ALTER TABLE lectures MODIFY semester_date VARCHAR(10) NOT NULL; diff --git a/src/main/resources/db/migration/V106__alter_lecture_class_time_size.sql b/src/main/resources/db/migration/V106__alter_lecture_class_time_size.sql new file mode 100644 index 000000000..22d2dfaa7 --- /dev/null +++ b/src/main/resources/db/migration/V106__alter_lecture_class_time_size.sql @@ -0,0 +1 @@ +ALTER TABLE lectures MODIFY class_time VARCHAR(255) NOT NULL; diff --git a/src/main/resources/db/migration/V107__update_coop_shop.sql b/src/main/resources/db/migration/V107__update_coop_shop.sql new file mode 100644 index 000000000..829b4aee5 --- /dev/null +++ b/src/main/resources/db/migration/V107__update_coop_shop.sql @@ -0,0 +1,45 @@ +INSERT INTO `coop_semester` (`semester`, `from_date`, `to_date`, `is_applied`) +VALUES ('24-동계방학', '2024-12-23', '2025-03-02', 1); + +SET @SEMESTER_ID = LAST_INSERT_ID(); + +UPDATE `coop_semester` +SET `is_applied` = 0 +WHERE `id` = 1; + +INSERT INTO `coop_shop` (`name`, `phone`, `location`, `remarks`, `semester_id`) +VALUES ('학생식당', '041-560-1278', '학생회관 2층', '비계절학기 주말 미운영', @SEMESTER_ID), + ('복지관식당', '041-560-1778', '복지관 2층', '복지관식당 c코너 별도 운영(계절학기까지)', @SEMESTER_ID), + ('대즐', '041-560-1779', '복지관 1층', '배달 가능', @SEMESTER_ID), + ('서점', '041-560-1756', '복지관 1층', '점심시간 12:00 - 13:00', @SEMESTER_ID), + ('세탁소', '041-560-1763', '학생회관 2층', NULL, @SEMESTER_ID), + ('복사실', '041-560-1093', '학생회관 2층', '점심시간 11:30 - 12:30', @SEMESTER_ID), + ('복지관 참빛관 편의점', '041-560-1093', '복지관 1층, 참빛관 1층', NULL, @SEMESTER_ID), + ('미용실', '041-560-1769', '학생회관 1층', '예약제운영', @SEMESTER_ID), + ('오락실', '041-560-1472', '학생회관 1층', NULL, @SEMESTER_ID); + +SET @COOP_SHOP_ID = LAST_INSERT_ID(); + +INSERT INTO `coop_opens` (`coop_shop_id`, `type`, `day_of_week`, `open_time`, `close_time`) +VALUES (@COOP_SHOP_ID, '아침', 'WEEKDAYS', '08:00', '09:00'), + (@COOP_SHOP_ID, '점심', 'WEEKDAYS', '11:30', '13:30'), + (@COOP_SHOP_ID, '저녁', 'WEEKDAYS', '17:30', '18:30'), + (@COOP_SHOP_ID, '아침', 'WEEKEND', '휴점(예약)', '휴점(예약)'), + (@COOP_SHOP_ID, '점심', 'WEEKEND', '11:30', '13:30'), + (@COOP_SHOP_ID, '저녁', 'WEEKEND', '17:30', '18:30'), + (@COOP_SHOP_ID + 1, '점심', 'WEEKDAYS', '11:40', '13:30'), + (@COOP_SHOP_ID + 1, '점심', 'WEEKEND', '미운영', '미운영'), + (@COOP_SHOP_ID + 2, NULL, 'WEEKDAYS', '08:30', '19:00'), + (@COOP_SHOP_ID + 2, NULL, 'WEEKDAYS', '휴점', '휴점'), + (@COOP_SHOP_ID + 3, NULL, 'WEEKDAYS', '09:00', '18:00'), + (@COOP_SHOP_ID + 3, NULL, 'WEEKEND', '휴점', '휴점'), + (@COOP_SHOP_ID + 4, NULL, 'WEEKDAYS', '10:30', '16:00'), + (@COOP_SHOP_ID + 4, NULL, 'WEEKEND', '휴점', '휴점'), + (@COOP_SHOP_ID + 5, NULL, 'WEEKDAYS', '10:00', '16:00'), + (@COOP_SHOP_ID + 5, NULL, 'WEEKEND', '휴점', '휴점'), + (@COOP_SHOP_ID + 6, NULL, 'WEEKDAYS', '24시간', '24시간'), + (@COOP_SHOP_ID + 6, NULL, 'WEEKEND', '24시간', '24시간'), + (@COOP_SHOP_ID + 7, NULL, 'WEEKDAYS', '10:00', '16:00'), + (@COOP_SHOP_ID + 7, NULL, 'WEEKEND', '휴점', '휴점'), + (@COOP_SHOP_ID + 8, NULL, 'WEEKDAYS', '24시간', '24시간'), + (@COOP_SHOP_ID + 8, NULL, 'WEEKEND', '24시간', '24시간'); diff --git a/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java index acf081641..318658dfa 100644 --- a/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java @@ -20,7 +20,9 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.domain.coop.model.DiningSoldOutCache; +import in.koreatech.koin.domain.coop.repository.DiningNotifyCacheRepository; import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; +import in.koreatech.koin.domain.coop.service.CoopService; import in.koreatech.koin.domain.dining.model.Dining; import in.koreatech.koin.domain.dining.repository.DiningRepository; import in.koreatech.koin.domain.user.model.User; @@ -48,7 +50,14 @@ class DiningApiTest extends AcceptanceTest { @Autowired private CoopShopFixture coopShopFixture; + @Autowired + private CoopService coopService; + + @Autowired + private DiningNotifyCacheRepository diningNotifyCacheRepository; + private Dining A코너_점심; + private Dining B코너_점심; private User coop_준기; private String token_준기; private User owner_현수; @@ -63,6 +72,7 @@ void setUp() { owner_현수 = userFixture.현수_사장님().getUser(); token_현수 = userFixture.getToken(owner_현수); A코너_점심 = diningFixture.A코너_점심(LocalDate.parse("2024-01-15")); + B코너_점심 = diningFixture.B코너_점심(LocalDate.parse("2024-01-15")); } @Test @@ -88,6 +98,28 @@ void setUp() { "땡초부추전", "누룽지탕" ], + "image_url": "https://stage.koreatech.in/image.jpg", + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00", + "soldout_at": null, + "changed_at": null, + "likes": 0, + "is_liked" : false + }, + { + "id": 2, + "date": "2024-01-15", + "type": "LUNCH", + "place": "B코너", + "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", @@ -118,30 +150,52 @@ void setUp() { ) .andExpect(status().isOk()) .andExpect(content().json(""" - [ - { - "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, - "likes": 0, - "is_liked" : false - } - ] + [ + { + "id": 1, + "date": "2024-01-15", + "type": "LUNCH", + "place": "A코너", + "price_card": 6000, + "price_cash": 6000, + "kcal": 881, + "menu": [ + "병아리콩밥", + "(탕)소고기육개장", + "땡초부추전", + "누룽지탕" + ], + "image_url": "https://stage.koreatech.in/image.jpg", + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00", + "soldout_at": null, + "changed_at": null, + "likes": 0, + "is_liked" : false + }, + { + "id": 2, + "date": "2024-01-15", + "type": "LUNCH", + "place": "B코너", + "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, + "likes": 0, + "is_liked" : false + } + ] """)); } @@ -290,27 +344,6 @@ void setUp() { .andReturn(); } - @Test - void 특정_식단의_좋아요_중복해서_누르면_에러() throws Exception { - mockMvc.perform( - patch("/dining/like") - .header("Authorization", "Bearer " + token_준기) - .param("diningId", String.valueOf(1)) - .contentType(MediaType.APPLICATION_JSON) - ) - .andExpect(status().isOk()) - .andReturn(); - - mockMvc.perform( - patch("/dining/like") - .header("Authorization", "Bearer " + token_준기) - .param("diningId", String.valueOf(1)) - .contentType(MediaType.APPLICATION_JSON) - ) - .andExpect(status().isConflict()) - .andReturn(); - } - @Test void 좋아요_누른_식단은_isLiked가_true로_반환() throws Exception { mockMvc.perform( @@ -345,13 +378,35 @@ void setUp() { "땡초부추전", "누룽지탕" ], - "image_url": null, + "image_url": "https://stage.koreatech.in/image.jpg", "created_at": "2024-01-15 12:00:00", "updated_at": "2024-01-15 12:00:00", "soldout_at": null, "changed_at": null, "likes": 1, "is_liked" : true + }, + { + "id": 2, + "date": "2024-01-15", + "type": "LUNCH", + "place": "B코너", + "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, + "likes": 0, + "is_liked" : false } ] """)) @@ -383,6 +438,28 @@ void setUp() { "땡초부추전", "누룽지탕" ], + "image_url": "https://stage.koreatech.in/image.jpg", + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00", + "soldout_at": null, + "changed_at": null, + "likes": 0, + "is_liked" : false + }, + { + "id": 2, + "date": "2024-01-15", + "type": "LUNCH", + "place": "B코너", + "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", @@ -397,7 +474,8 @@ void setUp() { } @Test - void 이미지_업로드를_한다_품절_알림이_발송된다() throws Exception { + void 식단_이미지를_업로드_한다() throws Exception { + Dining A코너_저녁 = diningFixture.A코너_저녁(LocalDate.parse("2024-01-15")); String imageUrl = "https://stage.koreatech.in/image.jpg"; mockMvc.perform( patch("/coop/dining/image") @@ -407,38 +485,47 @@ void setUp() { "menu_id": "%s", "image_url": "%s" } - """, A코너_점심.getId(), imageUrl)) + """, A코너_저녁.getId(), imageUrl)) .param("diningId", String.valueOf(1)) .contentType(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()); - forceVerify(() -> verify(coopEventListener).onDiningImageUploadRequest(any())); clear(); setUp(); } + /* TODO: 알림 로직 테스트 후 주석 제거 @Test - void 해당_식사시간_외에_이미지_업로드를_한다_품절_알림이_발송되지_않는다() throws Exception { - Dining A코너_저녁 = diningFixture.A코너_저녁(LocalDate.parse("2024-01-15")); - String imageUrl = "https://stage.koreatech.in/image.jpg"; - mockMvc.perform( - patch("/coop/dining/image") - .header("Authorization", "Bearer " + token_준기) - .content(String.format(""" - { - "menu_id": "%s", - "image_url": "%s" - } - """, A코너_저녁.getId(), imageUrl)) - .param("diningId", String.valueOf(1)) - .contentType(MediaType.APPLICATION_JSON) - ) - .andExpect(status().isOk()); + void 이미지가_모두_존재하지_않으면_알림이_발송되지_않는다() throws Exception { + coopService.sendDiningNotify(); + forceVerify(() -> verify(coopEventListener, never()).onDiningImageUploadRequest(any())); clear(); setUp(); } + @Test + void 이미지가_모두_존재하고_오픈시간이고_Redis에_키가_있으면_알림이_발송되지_않는다() throws Exception { + String diningNotifyId = LocalDate.now(clock).toString() + LUNCH; + diningNotifyCacheRepository.save(DiningNotifyCache.from(diningNotifyId)); + coopService.sendDiningNotify(); + + forceVerify(() -> verify(coopEventListener, never()).onDiningImageUploadRequest(any())); + clear(); + setUp(); + } + + @Test + void 이미지가_모두_존재하고_오픈시간이고_Redis에_키가_없으면_알림이_발송된다() throws Exception { + B코너_점심.setImageUrl("https://stage.koreatech.in/image.jpg"); + diningRepository.save(B코너_점심); + coopService.sendDiningNotify(); + + forceVerify(() -> verify(coopEventListener).onDiningImageUploadRequest(any())); + clear(); + setUp(); + }*/ + @Test void 특정_메뉴_특정_코너의_식단을_검색한다() throws Exception { mockMvc.perform( @@ -466,7 +553,7 @@ void setUp() { "땡초부추전", "누룽지탕" ], - "image_url": null, + "image_url": "https://stage.koreatech.in/image.jpg", "created_at": "2024-01-15 12:00:00", "soldout_at": "2024-01-15 12:00:00", "changed_at": "2024-01-15 12:00:00", @@ -529,7 +616,7 @@ void setUp() { "땡초부추전", "누룽지탕" ], - "image_url": null, + "image_url": "https://stage.koreatech.in/image.jpg", "created_at": "2024-01-15 12:00:00", "soldout_at": "2024-01-15 12:00:00", "changed_at": "2024-01-15 12:00:00", diff --git a/src/test/java/in/koreatech/koin/fixture/DiningFixture.java b/src/test/java/in/koreatech/koin/fixture/DiningFixture.java index 9889e8668..c89829a4e 100644 --- a/src/test/java/in/koreatech/koin/fixture/DiningFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/DiningFixture.java @@ -63,6 +63,7 @@ public DiningFixture(DiningRepository diningRepository) { .kcal(881) .menu(""" ["병아리콩밥", "(탕)소고기육개장", "땡초부추전", "누룽지탕"]""") + .imageUrl("https://stage.koreatech.in/image.jpg") .likes(0) .build() );