diff --git a/build.gradle b/build.gradle index b893e71e..5b2a378b 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,9 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // 이메일 전송 + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/src/main/java/com/memesphere/domain/chartdata/repository/ChartDataRepository.java b/src/main/java/com/memesphere/domain/chartdata/repository/ChartDataRepository.java index 7cc5e8eb..8d4d5ebe 100644 --- a/src/main/java/com/memesphere/domain/chartdata/repository/ChartDataRepository.java +++ b/src/main/java/com/memesphere/domain/chartdata/repository/ChartDataRepository.java @@ -11,6 +11,7 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface ChartDataRepository extends JpaRepository { @Query("SELECT SUM(c.volume) FROM ChartData c " + @@ -29,7 +30,11 @@ public interface ChartDataRepository extends JpaRepository { List findByMemeCoinOrderByRecordedTimeDesc(MemeCoin memeCoin); - //TODO: 위아래 코드 합치는 방법 찾기 - List findByMemeCoinOrderByRecordedTimeDesc(MemeCoin memeCoin, Pageable pageable); + List findByMemeCoinAndRecordedTimeAfterOrderByRecordedTimeDesc(MemeCoin memeCoin, LocalDateTime recordedTime, Pageable pageable); + @Query("SELECT c FROM ChartData c " + + "WHERE c.memeCoin.id = :coinId " + + "AND c.recordedTime = " + + "(SELECT MAX(c2.recordedTime) FROM ChartData c2 WHERE c2.memeCoin = c.memeCoin)") + Optional findLatestByCoinId(Long coinId); } diff --git a/src/main/java/com/memesphere/domain/chartdata/scheduler/ChartDataScheduler.java b/src/main/java/com/memesphere/domain/chartdata/scheduler/ChartDataScheduler.java index b7b69441..f4073eb4 100644 --- a/src/main/java/com/memesphere/domain/chartdata/scheduler/ChartDataScheduler.java +++ b/src/main/java/com/memesphere/domain/chartdata/scheduler/ChartDataScheduler.java @@ -3,17 +3,20 @@ import com.memesphere.domain.binance.dto.response.BinanceTickerResponse; import com.memesphere.domain.binance.service.BinanceQueryService; import com.memesphere.domain.chartdata.entity.ChartData; +import com.memesphere.domain.notification.service.PushNotificationService; import com.memesphere.global.apipayload.code.status.ErrorStatus; import com.memesphere.global.apipayload.exception.GeneralException; import com.memesphere.domain.memecoin.entity.MemeCoin; import com.memesphere.domain.memecoin.repository.MemeCoinRepository; import com.memesphere.domain.memecoin.service.MemeCoinQueryService; +import com.memesphere.global.jwt.LoggedInUserStore; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Set; import static com.memesphere.domain.chartdata.converter.ChartDataConverter.toChartData; @@ -23,10 +26,15 @@ public class ChartDataScheduler { private final MemeCoinRepository memeCoinRepository; private final BinanceQueryService binanceQueryService; private final MemeCoinQueryService memeCoinQueryService; + private final LoggedInUserStore loggedInUserStore; + private final PushNotificationService pushNotificationService; @Scheduled(cron = "0 0/10 * * * ?") // 0, 10, 20, 30, 40, 50분에 실행 @Transactional public void updateChartData() { + + Set loggedInUsers = loggedInUserStore.getLoggedInUsers(); + List memeCoins = memeCoinRepository.findAll(); for (MemeCoin memeCoin : memeCoins) { @@ -34,7 +42,7 @@ public void updateChartData() { String symbol = memeCoin.getSymbol() + "USDT"; BinanceTickerResponse response = binanceQueryService.getTickerData(symbol); - ChartData chartData = toChartData(memeCoin,response); + ChartData chartData = toChartData(memeCoin, response); memeCoinQueryService.updateChartData(memeCoin.getId(), chartData); @@ -42,6 +50,10 @@ public void updateChartData() { throw new GeneralException(ErrorStatus.CANNOT_LOAD_CHARTDATA); } } + + for (Long userId : loggedInUsers) { + pushNotificationService.send(userId); + } } } diff --git a/src/main/java/com/memesphere/domain/chartdata/service/ChartDataQueryServiceImpl.java b/src/main/java/com/memesphere/domain/chartdata/service/ChartDataQueryServiceImpl.java index d5435d5d..651ded2a 100644 --- a/src/main/java/com/memesphere/domain/chartdata/service/ChartDataQueryServiceImpl.java +++ b/src/main/java/com/memesphere/domain/chartdata/service/ChartDataQueryServiceImpl.java @@ -3,6 +3,7 @@ import com.memesphere.domain.chartdata.entity.ChartData; import com.memesphere.domain.chartdata.repository.ChartDataRepository; import com.memesphere.domain.memecoin.entity.MemeCoin; +import com.memesphere.domain.notification.service.PushNotificationService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/memesphere/domain/collection/controller/CollectionRestController.java b/src/main/java/com/memesphere/domain/collection/controller/CollectionRestController.java index b65aabdf..cf22cd4c 100644 --- a/src/main/java/com/memesphere/domain/collection/controller/CollectionRestController.java +++ b/src/main/java/com/memesphere/domain/collection/controller/CollectionRestController.java @@ -1,6 +1,8 @@ package com.memesphere.domain.collection.controller; import com.memesphere.domain.collection.service.CollectionCommandService; +import com.memesphere.domain.search.entity.SortType; +import com.memesphere.domain.search.entity.ViewType; import com.memesphere.global.apipayload.ApiResponse; import com.memesphere.domain.collection.entity.Collection; import com.memesphere.domain.collection.dto.response.CollectionPageResponse; @@ -32,6 +34,8 @@ public class CollectionRestController { @GetMapping("/collection") @Operation(summary = "사용자의 밈코인 콜렉션 모음 조회 API") public ApiResponse getCollectionList ( + @RequestParam(name = "viewType", defaultValue = "GRID") ViewType viewType, // 뷰 타입 (grid 또는 list) + @RequestParam(name = "sortType", defaultValue = "PRICE_CHANGE") SortType sortType, // 정렬 기준 (MKTCap, 24h Volume, Price) @AuthenticationPrincipal CustomUserDetails userDetails, // 현재 로그인한 사용자 @CheckPage @RequestParam(name = "page") Integer page // 페이지 번호 ) { @@ -41,8 +45,8 @@ public ApiResponse getCollectionList ( // 유저를 찾지 못하면(로그인을 안 했으면) 콜렉션 접근 못하도록 에러 처리 if (userId == null) throw new GeneralException(ErrorStatus.USER_NOT_FOUND); - Page collectionPage = collectionQueryService.getCollectionPage(userId, pageNumber); - return ApiResponse.onSuccess(CollectionConverter.toCollectionPageDTO(collectionPage)); + Page collectionPage = collectionQueryService.getCollectionPage(userId, pageNumber, viewType, sortType); + return ApiResponse.onSuccess(CollectionConverter.toCollectionPageDTO(collectionPage, viewType)); } @PostMapping("/collection/{coinId}") diff --git a/src/main/java/com/memesphere/domain/collection/converter/CollectionConverter.java b/src/main/java/com/memesphere/domain/collection/converter/CollectionConverter.java index 0d062b6b..c83d19b2 100644 --- a/src/main/java/com/memesphere/domain/collection/converter/CollectionConverter.java +++ b/src/main/java/com/memesphere/domain/collection/converter/CollectionConverter.java @@ -1,13 +1,17 @@ package com.memesphere.domain.collection.converter; import com.memesphere.domain.chartdata.entity.ChartData; +import com.memesphere.domain.collection.dto.response.CollectionListPreviewResponse; import com.memesphere.domain.collection.entity.Collection; import com.memesphere.domain.memecoin.entity.MemeCoin; import com.memesphere.domain.collection.dto.response.CollectionPageResponse; -import com.memesphere.domain.collection.dto.response.CollectionPreviewResponse; +import com.memesphere.domain.collection.dto.response.CollectionGridPreviewResponse; +import com.memesphere.domain.search.entity.ViewType; import com.memesphere.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.data.domain.Page; +import java.math.BigDecimal; import java.util.List; import java.util.stream.Collectors; @@ -18,14 +22,24 @@ public static Collection toCollection(User user, MemeCoin coin) { .user(user).memeCoin(coin).build(); } - public static CollectionPageResponse toCollectionPageDTO(Page collectionPage) { - List collectionItems = collectionPage.getContent().stream() - .map(collection -> toCollectionPreviewDTO(collection)) - .collect(Collectors.toList()); + public static CollectionPageResponse toCollectionPageDTO(Page collectionPage, ViewType viewType) { + List gridItems = null; + List listItems = null; + + if (viewType == ViewType.GRID) { + gridItems = collectionPage.stream() + .map(collection -> toCollectionGridPreviewDTO(collection)) + .collect(Collectors.toList()); + } else if (viewType == ViewType.LIST) { + listItems = collectionPage.stream() + .map(collection -> toCollectionListPreviewDTO(collection)) + .collect(Collectors.toList()); + } return CollectionPageResponse.builder() - .collectionItems(collectionItems) - .listSize(collectionItems.size()) + .gridItems(gridItems) + .listItems(listItems) + .listSize(collectionPage.getContent().size()) .totalPage(collectionPage.getTotalPages()) .totalElements(collectionPage.getTotalElements()) .isFirst(collectionPage.isFirst()) @@ -33,11 +47,11 @@ public static CollectionPageResponse toCollectionPageDTO(Page collec .build(); } - private static CollectionPreviewResponse toCollectionPreviewDTO(Collection collection) { + private static CollectionGridPreviewResponse toCollectionGridPreviewDTO(Collection collection) { MemeCoin memeCoin = collection.getMemeCoin(); ChartData chartData = memeCoin.getChartDataList().get(0); - return CollectionPreviewResponse.builder() + return CollectionGridPreviewResponse.builder() .coinId(memeCoin.getId()) .name(memeCoin.getName()) .symbol(memeCoin.getSymbol()) @@ -49,4 +63,19 @@ private static CollectionPreviewResponse toCollectionPreviewDTO(Collection colle .priceChangeRate(chartData.getPriceChangeRate()) .build(); } + + public static CollectionListPreviewResponse toCollectionListPreviewDTO(Collection collection) { + MemeCoin memeCoin = collection.getMemeCoin(); + ChartData chartData = memeCoin.getChartDataList().get(0); + + return CollectionListPreviewResponse.builder() + .coinId(memeCoin.getId()) + .name(memeCoin.getName()) + .symbol(memeCoin.getSymbol()) + .currentPrice(chartData.getPrice()) + .priceChangeRate(chartData.getPriceChangeRate()) + .weightedAveragePrice(chartData.getWeighted_average_price()) + .volume(chartData.getVolume()) + .build(); + } } diff --git a/src/main/java/com/memesphere/domain/collection/dto/response/CollectionPreviewResponse.java b/src/main/java/com/memesphere/domain/collection/dto/response/CollectionGridPreviewResponse.java similarity index 96% rename from src/main/java/com/memesphere/domain/collection/dto/response/CollectionPreviewResponse.java rename to src/main/java/com/memesphere/domain/collection/dto/response/CollectionGridPreviewResponse.java index 7bea26c9..0fd852b5 100644 --- a/src/main/java/com/memesphere/domain/collection/dto/response/CollectionPreviewResponse.java +++ b/src/main/java/com/memesphere/domain/collection/dto/response/CollectionGridPreviewResponse.java @@ -12,7 +12,7 @@ @Builder @AllArgsConstructor @NoArgsConstructor -public class CollectionPreviewResponse { +public class CollectionGridPreviewResponse { @Schema(description = "밈코인 id", example = "1") Long coinId; @Schema(description = "밈코인 name", example = "도지코인") diff --git a/src/main/java/com/memesphere/domain/collection/dto/response/CollectionListPreviewResponse.java b/src/main/java/com/memesphere/domain/collection/dto/response/CollectionListPreviewResponse.java new file mode 100644 index 00000000..433f9bdc --- /dev/null +++ b/src/main/java/com/memesphere/domain/collection/dto/response/CollectionListPreviewResponse.java @@ -0,0 +1,31 @@ +package com.memesphere.domain.collection.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CollectionListPreviewResponse { + @Schema(description = "밈코인 id", example = "1") + Long coinId; + @Schema(description = "밈코인 name", example = "도지코인") + String name; + @Schema(description = "밈코인 symbol", example = "DOGE") + String symbol; + @Schema(description = "차트 데이터의 price", example = "2000") + BigDecimal currentPrice; + @Schema(description = "차트 데이터의 price_change_rate", example = "+2.4%") + BigDecimal priceChangeRate; + @Schema(description = "차트 데이터의 weighted average price", example = "10000") + BigDecimal weightedAveragePrice; // market cap 대신 사용 + @Schema(description = "차트 데이터의 volume", example = "5") + BigDecimal volume; +} + diff --git a/src/main/java/com/memesphere/domain/collection/dto/response/CollectionPageResponse.java b/src/main/java/com/memesphere/domain/collection/dto/response/CollectionPageResponse.java index 92ecaf3f..cae3c7e2 100644 --- a/src/main/java/com/memesphere/domain/collection/dto/response/CollectionPageResponse.java +++ b/src/main/java/com/memesphere/domain/collection/dto/response/CollectionPageResponse.java @@ -1,5 +1,8 @@ package com.memesphere.domain.collection.dto.response; +import com.memesphere.domain.collection.entity.Collection; +import com.memesphere.domain.search.dto.response.SearchGridPreviewResponse; +import com.memesphere.domain.search.dto.response.SearchListPreviewResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,8 +16,10 @@ @AllArgsConstructor @NoArgsConstructor public class CollectionPageResponse { - @Schema(description = "콜렉션 아이템들") - List collectionItems; + @Schema(description = "gridView용 컬렉션 아이템들") + List gridItems; // Grid View용 데이터 + @Schema(description = "listView용 컬렉션 아이템들") + List listItems; // List View용 데이터 Integer listSize; Integer totalPage; Long totalElements; diff --git a/src/main/java/com/memesphere/domain/collection/repository/CollectionRepository.java b/src/main/java/com/memesphere/domain/collection/repository/CollectionRepository.java index c9f9a722..f4edba53 100644 --- a/src/main/java/com/memesphere/domain/collection/repository/CollectionRepository.java +++ b/src/main/java/com/memesphere/domain/collection/repository/CollectionRepository.java @@ -6,12 +6,27 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; public interface CollectionRepository extends JpaRepository { - Page findAllByUserId(Long userId, Pageable pageable); List findAllByUserId(Long userId); Optional findByUserAndMemeCoin(User user, MemeCoin memeCoin); + + @Query("SELECT c FROM Collection c " + + "JOIN c.memeCoin m " + + "JOIN m.chartDataList cd " + + "WHERE c.user.id = :userId " + + "AND cd.recordedTime = (SELECT MAX(cd2.recordedTime) FROM ChartData cd2 WHERE cd2.memeCoin = m) " + + "ORDER BY " + + " CASE WHEN :sortField = 'priceChange' THEN cd.priceChange END DESC, " + + " CASE WHEN :sortField = 'volume' THEN cd.volume END DESC, " + + " CASE WHEN :sortField = 'price' THEN cd.price END DESC") + Page findWithLatestChartDataSorted( + @Param("userId") Long userId, + @Param("sortField") String sortField, + Pageable pageable); } diff --git a/src/main/java/com/memesphere/domain/collection/service/CollectionQueryService.java b/src/main/java/com/memesphere/domain/collection/service/CollectionQueryService.java index d55b7ac1..32693b44 100644 --- a/src/main/java/com/memesphere/domain/collection/service/CollectionQueryService.java +++ b/src/main/java/com/memesphere/domain/collection/service/CollectionQueryService.java @@ -1,11 +1,13 @@ package com.memesphere.domain.collection.service; import com.memesphere.domain.collection.entity.Collection; +import com.memesphere.domain.search.entity.SortType; +import com.memesphere.domain.search.entity.ViewType; import org.springframework.data.domain.Page; import java.util.List; public interface CollectionQueryService { - Page getCollectionPage(Long userId, Integer pageNumber); + Page getCollectionPage(Long userId, Integer pageNumber, ViewType viewType, SortType sortType); List getUserCollectionIds(Long userId); } diff --git a/src/main/java/com/memesphere/domain/collection/service/CollectionQueryServiceImpl.java b/src/main/java/com/memesphere/domain/collection/service/CollectionQueryServiceImpl.java index 54640379..ad3ef60a 100644 --- a/src/main/java/com/memesphere/domain/collection/service/CollectionQueryServiceImpl.java +++ b/src/main/java/com/memesphere/domain/collection/service/CollectionQueryServiceImpl.java @@ -2,10 +2,15 @@ import com.memesphere.domain.collection.entity.Collection; import com.memesphere.domain.collection.repository.CollectionRepository; +import com.memesphere.domain.search.entity.SortType; +import com.memesphere.domain.search.entity.ViewType; import com.memesphere.domain.user.repository.UserRepository; +import com.memesphere.global.apipayload.code.status.ErrorStatus; +import com.memesphere.global.apipayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,8 +25,24 @@ public class CollectionQueryServiceImpl implements CollectionQueryService { @Transactional(readOnly = true) @Override - public Page getCollectionPage(Long userId, Integer pageNumber) { - Page collectionPage = collectionRepository.findAllByUserId(userId, PageRequest.of(pageNumber, 9 )); + public Page getCollectionPage(Long userId, Integer pageNumber, ViewType viewType, SortType sortType) { + + int pageSize = switch (viewType) { + case GRID -> 9; + case LIST -> 20; + default -> throw new GeneralException(ErrorStatus.UNSUPPORTED_VIEW_TYPE); + }; + + String sortField = switch (sortType) { + case PRICE_CHANGE -> "priceChange"; + case VOLUME_24H -> "volume"; + case PRICE -> "price"; + default -> throw new GeneralException(ErrorStatus.UNSUPPORTED_SORT_TYPE); + }; + + Pageable pageable = PageRequest.of(pageNumber, pageSize); + + Page collectionPage = collectionRepository.findWithLatestChartDataSorted(userId, sortField, pageable); return collectionPage; } diff --git a/src/main/java/com/memesphere/domain/detail/controller/DetailController.java b/src/main/java/com/memesphere/domain/detail/controller/DetailController.java index 8b37fa89..e8335c6f 100644 --- a/src/main/java/com/memesphere/domain/detail/controller/DetailController.java +++ b/src/main/java/com/memesphere/domain/detail/controller/DetailController.java @@ -1,8 +1,10 @@ package com.memesphere.domain.detail.controller; +import com.memesphere.domain.detail.dto.response.PriceInfoResponse; +import com.memesphere.domain.detail.service.DetailQueryServiceImpl; import com.memesphere.global.apipayload.ApiResponse; import com.memesphere.domain.detail.dto.response.DetailGetResponse; -import com.memesphere.domain.detail.service.DetailService; +import com.memesphere.domain.detail.service.DetailQueryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -17,7 +19,7 @@ @RequestMapping("/detail") public class DetailController { - private final DetailService detailService; + private final DetailQueryService detailQueryService; @GetMapping("/{memeId}") @Operation(summary = "밈코인 세부 정보 조회 API", @@ -35,10 +37,38 @@ public class DetailController { - "image": 밈코인 이미지 - "keywords": 밈코인 키워드 (리스트 형식) - "collectionActive": 컬렉션 유무 (저장 유무) + - "rank": 밈코인 순위(1 ~ 5위까지, 나머지 순위는 null) ```""") public ApiResponse getDetail(@PathVariable("memeId") Long memeId) { - DetailGetResponse detailGetResponse = detailService.getDetail(memeId); + DetailGetResponse detailGetResponse = detailQueryService.getDetail(memeId); return ApiResponse.onSuccess(detailGetResponse); } + + @GetMapping("/{memeId}/price-info") + @Operation(summary = "밈코인 가격 정보 조회 API", + description = """ + 해당 밈코인의 24시간 기준 가격 정보를 보여줍니다. \n + + **요청 형식**: + ``` + "memeId": 코인 아이디 + ``` + \n + **응답 형식**: + ``` + - "coinId": 코인 아이디 + - "price": 현재가 + - "priceChange": 가격 변화량 + - "priceChangeAbsolute": 가격 변화량(절대값) + - "priceChangeDirection": 밈코인 상승(up, 0 이상)/하락(down) + - "priceChangeRate": 가격 변화율 + - "weightedAveragePrice": 거래량 가중 평균 가격 + - "highPrice": 24h 최고가 + - "lowPrice": 24h 최저가 + ```""") + public ApiResponse getPriceInfo(@PathVariable("memeId") Long coinId) { + + return ApiResponse.onSuccess(detailQueryService.findPriceInfo(coinId)); + } } diff --git a/src/main/java/com/memesphere/domain/detail/converter/DetailConverter.java b/src/main/java/com/memesphere/domain/detail/converter/DetailConverter.java index 3a6150be..4185c102 100644 --- a/src/main/java/com/memesphere/domain/detail/converter/DetailConverter.java +++ b/src/main/java/com/memesphere/domain/detail/converter/DetailConverter.java @@ -1,8 +1,12 @@ package com.memesphere.domain.detail.converter; +import com.memesphere.domain.chartdata.entity.ChartData; +import com.memesphere.domain.detail.dto.response.PriceInfoResponse; import com.memesphere.domain.memecoin.entity.MemeCoin; import com.memesphere.domain.detail.dto.response.DetailGetResponse; +import java.math.BigDecimal; + public class DetailConverter { public static DetailGetResponse toDetailGetResponse(MemeCoin memeCoin) { @@ -15,6 +19,21 @@ public static DetailGetResponse toDetailGetResponse(MemeCoin memeCoin) { .image(memeCoin.getImage()) .keywords(memeCoin.getKeywords()) .collectionActive(memeCoin.getCollectionList().isEmpty()) + .rank(memeCoin.getTrendRank()) + .build(); + } + + public static PriceInfoResponse toPriceInfoResponse(MemeCoin memeCoin, ChartData data) { + return PriceInfoResponse.builder() + .coinId(memeCoin.getId()) + .price(data.getPrice()) + .priceChange(data.getPriceChange()) + .priceChangeAbsolute(data.getPriceChange().abs()) + .priceChangeDirection(data.getPriceChangeRate().compareTo(BigDecimal.ZERO) < 0 ? "down" : "up") + .priceChangeRate(data.getPriceChangeRate()) + .weightedAveragePrice(data.getWeighted_average_price()) + .highPrice(data.getHigh_price()) + .lowPrice(data.getLow_price()) .build(); } } diff --git a/src/main/java/com/memesphere/domain/detail/dto/response/DetailGetResponse.java b/src/main/java/com/memesphere/domain/detail/dto/response/DetailGetResponse.java index ad6bc729..b26a0f80 100644 --- a/src/main/java/com/memesphere/domain/detail/dto/response/DetailGetResponse.java +++ b/src/main/java/com/memesphere/domain/detail/dto/response/DetailGetResponse.java @@ -30,4 +30,7 @@ public class DetailGetResponse { @Schema(description = "컬렉션 유무", example = "True") private boolean collectionActive; + + @Schema(description = "밈코인 순위", example = "1") + private Integer rank; } diff --git a/src/main/java/com/memesphere/domain/detail/dto/response/PriceInfoResponse.java b/src/main/java/com/memesphere/domain/detail/dto/response/PriceInfoResponse.java new file mode 100644 index 00000000..b1cd2c02 --- /dev/null +++ b/src/main/java/com/memesphere/domain/detail/dto/response/PriceInfoResponse.java @@ -0,0 +1,38 @@ +package com.memesphere.domain.detail.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; + +@Getter +@Builder +public class PriceInfoResponse { + @Schema(description = "밈코인 아이디", example = "1") + private Long coinId; + + @Schema(description = "현재가", example = "0.20") + private BigDecimal price; + + @Schema(description = "가격 변화량", example = "-0.03") + private BigDecimal priceChange; + + @Schema(description = "가격 변화량 절대값", example = "0.03") + private BigDecimal priceChangeAbsolute; + + @Schema(description = "가격 변화 방향", example = "down") + private String priceChangeDirection; + + @Schema(description = "가격 변화율", example = "-6.35") + private BigDecimal priceChangeRate; + + @Schema(description = "거래량 가중 평균 가격", example = "-942.38") + private BigDecimal weightedAveragePrice; + + @Schema(description = "24h 최고가", example = "2500") + private BigDecimal highPrice; + + @Schema(description = "24h 최저가", example = "1500") + private BigDecimal lowPrice; +} diff --git a/src/main/java/com/memesphere/domain/detail/service/DetailQueryService.java b/src/main/java/com/memesphere/domain/detail/service/DetailQueryService.java new file mode 100644 index 00000000..8840b825 --- /dev/null +++ b/src/main/java/com/memesphere/domain/detail/service/DetailQueryService.java @@ -0,0 +1,10 @@ +package com.memesphere.domain.detail.service; + + +import com.memesphere.domain.detail.dto.response.DetailGetResponse; +import com.memesphere.domain.detail.dto.response.PriceInfoResponse; + +public interface DetailQueryService { + DetailGetResponse getDetail(Long meme_id); + PriceInfoResponse findPriceInfo(Long coinId); +} diff --git a/src/main/java/com/memesphere/domain/detail/service/DetailService.java b/src/main/java/com/memesphere/domain/detail/service/DetailQueryServiceImpl.java similarity index 57% rename from src/main/java/com/memesphere/domain/detail/service/DetailService.java rename to src/main/java/com/memesphere/domain/detail/service/DetailQueryServiceImpl.java index ef1eb011..7c4fc148 100644 --- a/src/main/java/com/memesphere/domain/detail/service/DetailService.java +++ b/src/main/java/com/memesphere/domain/detail/service/DetailQueryServiceImpl.java @@ -1,20 +1,23 @@ package com.memesphere.domain.detail.service; +import com.memesphere.domain.chartdata.entity.ChartData; +import com.memesphere.domain.chartdata.repository.ChartDataRepository; import com.memesphere.domain.detail.converter.DetailConverter; -import com.memesphere.global.apipayload.code.status.ErrorStatus; -import com.memesphere.global.apipayload.exception.GeneralException; -import com.memesphere.domain.memecoin.entity.MemeCoin; import com.memesphere.domain.detail.dto.response.DetailGetResponse; +import com.memesphere.domain.detail.dto.response.PriceInfoResponse; +import com.memesphere.domain.memecoin.entity.MemeCoin; import com.memesphere.domain.memecoin.repository.MemeCoinRepository; +import com.memesphere.global.apipayload.code.status.ErrorStatus; +import com.memesphere.global.apipayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor -public class DetailService { - +public class DetailQueryServiceImpl implements DetailQueryService { private final MemeCoinRepository memeCoinRepository; + private final ChartDataRepository chartDataRepository; @Transactional public DetailGetResponse getDetail(Long meme_id) { @@ -26,4 +29,14 @@ public DetailGetResponse getDetail(Long meme_id) { return detailGetResponse; } + + public PriceInfoResponse findPriceInfo(Long coinId) { + MemeCoin memeCoin = memeCoinRepository.findById(coinId) + .orElseThrow(() -> new GeneralException(ErrorStatus.MEMECOIN_NOT_FOUND)); + + ChartData data = chartDataRepository.findLatestByCoinId(coinId) + .orElseThrow(() -> new GeneralException(ErrorStatus.CHARTDATA_NOT_FOUND)); + + return DetailConverter.toPriceInfoResponse(memeCoin, data); + } } diff --git a/src/main/java/com/memesphere/domain/image/controller/ImageController.java b/src/main/java/com/memesphere/domain/image/controller/ImageController.java index 7e214166..f22c44ae 100644 --- a/src/main/java/com/memesphere/domain/image/controller/ImageController.java +++ b/src/main/java/com/memesphere/domain/image/controller/ImageController.java @@ -43,7 +43,7 @@ public ApiResponse getProfile(@AuthenticationPrincipal CustomUserDetails // 현재 로그인한 유저 정보에서 프로필 이미지 가져오기 String profileImage = profileService.getProfileImage(customUserDetails); - return ApiResponse.onSuccess((profileImage == null || profileImage.isEmpty()) ? "null" : profileImage); + return ApiResponse.onSuccess(profileImage); } } diff --git a/src/main/java/com/memesphere/domain/image/service/ProfileService.java b/src/main/java/com/memesphere/domain/image/service/ProfileService.java index f133f40b..5250ed61 100644 --- a/src/main/java/com/memesphere/domain/image/service/ProfileService.java +++ b/src/main/java/com/memesphere/domain/image/service/ProfileService.java @@ -1,7 +1,6 @@ package com.memesphere.domain.image.service; import com.memesphere.domain.user.entity.User; -import com.memesphere.domain.user.repository.UserRepository; import com.memesphere.global.apipayload.code.status.ErrorStatus; import com.memesphere.global.apipayload.exception.GeneralException; import com.memesphere.global.jwt.CustomUserDetails; diff --git a/src/main/java/com/memesphere/domain/naver/controller/SearchTrendController.java b/src/main/java/com/memesphere/domain/naver/controller/SearchTrendController.java new file mode 100644 index 00000000..b39c9e45 --- /dev/null +++ b/src/main/java/com/memesphere/domain/naver/controller/SearchTrendController.java @@ -0,0 +1,69 @@ +package com.memesphere.domain.naver.controller; + + +import com.memesphere.domain.naver.dto.request.SearchRequest; +import com.memesphere.domain.naver.dto.response.SearchResponse; +import com.memesphere.domain.naver.service.SearchTrendService; +import com.memesphere.global.apipayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; + + +@RestController +@RequestMapping("/naver") +@RequiredArgsConstructor +public class SearchTrendController { + + private final SearchTrendService searchTrendService; + + @ResponseBody + @PostMapping("/trends") + @Operation( + summary = "네이버 검색어 트렌드 조회 API", + description = """ + 해당 키워드에 대한 검색량 비율을 제공합니다. + groupName은 분류 목적이고, 검색 트렌드는 keywords 기준으로 계산됩니다. + 아래 예제와 같이 그룹으로 묶어서 한번에 하나 이상의 요청을 보낼 수 있습니다. + - `timeUnit` 옵션: `date` (일간) / `week` (주간) / `month` (월간) + """ + ) + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "검색 트렌드 요청 데이터", + required = true, + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "트렌드 조회 예제", + summary = "검색 트렌드 조회 요청 예제", + value = """ + { + "startDate": "2025-02-07", + "endDate": "2025-02-13", + "timeUnit": "date", + "keywordGroups": [ + { + "groupName": "밈코인", + "keywords": ["도지코인"] + }, + { + "groupName": "밈코인", + "keywords": ["시바이누"] + } + ] + } + """ + ) + ) + ) + public ApiResponse getSearchTrends( + @RequestBody SearchRequest searchRequest + ) { + SearchResponse response = searchTrendService.getSearchTrends(searchRequest); + return ApiResponse.onSuccess(response); + } +} + diff --git a/src/main/java/com/memesphere/domain/naver/dto/request/SearchRequest.java b/src/main/java/com/memesphere/domain/naver/dto/request/SearchRequest.java new file mode 100644 index 00000000..078b39dc --- /dev/null +++ b/src/main/java/com/memesphere/domain/naver/dto/request/SearchRequest.java @@ -0,0 +1,35 @@ +package com.memesphere.domain.naver.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class SearchRequest { + + @Schema(description = "조회 기간 시작 날짜", example = "2025-02-07") + private String startDate; + + @Schema(description = "조회 기간 종료 날짜", example = "2025-02-14") + private String endDate; + + @Schema(description = "구간 단위", example = "date") + private String timeUnit; + + @Schema(description = "검색어 그룹") + private List keywordGroups; + + @Getter + @Builder + public static class KeywordGroup { + @Schema(description = "그룹 이름", example = "밈코인") + private String groupName; + + @Schema(description = "조회할 검색어", example = "[\"도지코인\"]") + private List keywords; + } +} + diff --git a/src/main/java/com/memesphere/domain/naver/dto/response/SearchResponse.java b/src/main/java/com/memesphere/domain/naver/dto/response/SearchResponse.java new file mode 100644 index 00000000..aa554403 --- /dev/null +++ b/src/main/java/com/memesphere/domain/naver/dto/response/SearchResponse.java @@ -0,0 +1,41 @@ +package com.memesphere.domain.naver.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class SearchResponse { + @Schema(description = "조회 기간 시작 날짜", example = "2025-02-07") + private String startDate; + + @Schema(description = "조회 기간 종료 날짜", example = "2025-02-13") + private String endDate; + + @Schema(description = "구간 단위", example = "date") + private String timeUnit; + + private List results; + + @Getter + @Builder + public static class Result { + private String title; + private List keywords; + private List data; + + @Getter + @Builder + public static class Data { + @Schema(description = "구간별 시작 날짜", example = "2025-02-14") + private String period; + + @Schema(description = "구간별 검색량의 상대적 비율", example = "70.2") + private double ratio; + } + } +} + diff --git a/src/main/java/com/memesphere/domain/naver/service/SearchTrendService.java b/src/main/java/com/memesphere/domain/naver/service/SearchTrendService.java new file mode 100644 index 00000000..39165bde --- /dev/null +++ b/src/main/java/com/memesphere/domain/naver/service/SearchTrendService.java @@ -0,0 +1,58 @@ +package com.memesphere.domain.naver.service; + +import com.memesphere.domain.naver.dto.request.SearchRequest; +import com.memesphere.domain.naver.dto.response.SearchResponse; +import com.memesphere.global.apipayload.code.status.ErrorStatus; +import com.memesphere.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +public class SearchTrendService { + + private final RestTemplate restTemplate; + + @Value("${naver.url}") + private String apiUrl; + + @Value("${naver.client-id}") + private String clientId; + + @Value("${naver.secret}") + private String clientSecret; + + public SearchResponse getSearchTrends(SearchRequest request) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Naver-Client-Id", clientId); + headers.set("X-Naver-Client-Secret", clientSecret); + + HttpEntity entity = new HttpEntity<>(request, headers); + + ResponseEntity responseEntity = restTemplate.exchange( + apiUrl, + HttpMethod.POST, + entity, + SearchResponse.class + ); + + return responseEntity.getBody(); + + } catch (HttpClientErrorException.BadRequest e) { + throw new GeneralException(ErrorStatus.BAD_REQUEST); + } catch (HttpClientErrorException.Unauthorized e) { + throw new GeneralException(ErrorStatus.KEY_UNAUTHORIZED); + } catch (HttpClientErrorException.Forbidden e) { + throw new GeneralException(ErrorStatus.API_FORBIDDEN); + } catch (HttpServerErrorException e) { + throw new GeneralException(ErrorStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/com/memesphere/domain/notification/controller/PushNotificationController.java b/src/main/java/com/memesphere/domain/notification/controller/PushNotificationController.java index 4a2b1529..90b0d38d 100644 --- a/src/main/java/com/memesphere/domain/notification/controller/PushNotificationController.java +++ b/src/main/java/com/memesphere/domain/notification/controller/PushNotificationController.java @@ -26,6 +26,8 @@ public class PushNotificationController { @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) //서버가 클라이언트에게 이벤트 스트림을 전송한다는 것을 명시 @Operation(summary = "알림 전송 API", description = """ + 클라이언트와 서버 연결을 시작합니다. \n + 연결은 1시간 동안 유지됩니다. 등록한 알림이 기준 시간 내 변동성에 해당하는 경우 알림을 전송합니다. \n 변동성은 직접 계산하지 않고 외부 API에서 받아오는 정보를 기준으로 하고 있습니다. """) diff --git a/src/main/java/com/memesphere/domain/notification/converter/NotificationConverter.java b/src/main/java/com/memesphere/domain/notification/converter/NotificationConverter.java index b6682fa7..fb1f53c9 100644 --- a/src/main/java/com/memesphere/domain/notification/converter/NotificationConverter.java +++ b/src/main/java/com/memesphere/domain/notification/converter/NotificationConverter.java @@ -18,6 +18,7 @@ public static Notification toNotification(NotificationRequest notificationReques .stTime(notificationRequest.getStTime()) .isRising(notificationRequest.getIsRising()) .user(user) + .isOn(true) .build(); } @@ -29,6 +30,7 @@ public static NotificationResponse toNotificationCreateResponse(Notification not .volatility(notification.getVolatility()) .stTime(notification.getStTime()) .isRising(notification.getIsRising()) + .isOn(notification.getIsOn()) .build(); } diff --git a/src/main/java/com/memesphere/domain/notification/repository/EmitterRepositoryImpl.java b/src/main/java/com/memesphere/domain/notification/repository/EmitterRepositoryImpl.java index 299f8a38..01097d91 100644 --- a/src/main/java/com/memesphere/domain/notification/repository/EmitterRepositoryImpl.java +++ b/src/main/java/com/memesphere/domain/notification/repository/EmitterRepositoryImpl.java @@ -29,7 +29,7 @@ public void deleteById(String emitterId) { @Override public Map findAllEmitterStartWithByUserId(String UserId) { return emitters.entrySet().stream() - .filter(entry -> entry.getKey().startsWith(UserId)) + .filter(entry -> entry.getKey().startsWith(UserId + "_")) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } diff --git a/src/main/java/com/memesphere/domain/notification/service/CoinNotificationServiceImpl.java b/src/main/java/com/memesphere/domain/notification/service/CoinNotificationServiceImpl.java index 56aaf93b..c7d6c5c8 100644 --- a/src/main/java/com/memesphere/domain/notification/service/CoinNotificationServiceImpl.java +++ b/src/main/java/com/memesphere/domain/notification/service/CoinNotificationServiceImpl.java @@ -77,8 +77,8 @@ public String modifyNotification(Long notificationId) { @Override public NotificationListResponse removeNotification(Long notificationId) { - MemeCoin memeCoin = memeCoinRepository.findById(notificationId) - .orElseThrow(() -> new GeneralException(ErrorStatus.MEMECOIN_NOT_FOUND)); + Notification existingNotification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NOTIFICATION_NOT_FOUND)); notificationRepository.deleteById(notificationId); diff --git a/src/main/java/com/memesphere/domain/notification/service/PushNotificationService.java b/src/main/java/com/memesphere/domain/notification/service/PushNotificationService.java index f004a884..edc2ae64 100644 --- a/src/main/java/com/memesphere/domain/notification/service/PushNotificationService.java +++ b/src/main/java/com/memesphere/domain/notification/service/PushNotificationService.java @@ -1,9 +1,8 @@ package com.memesphere.domain.notification.service; -import com.memesphere.domain.notification.entity.Notification; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; public interface PushNotificationService { SseEmitter subscribe(Long userId, String lastEventId); - void send(Notification notification, Long userId); + void send(Long userId); } diff --git a/src/main/java/com/memesphere/domain/notification/service/PushNotificationServiceImpl.java b/src/main/java/com/memesphere/domain/notification/service/PushNotificationServiceImpl.java index 2252361b..3155e832 100644 --- a/src/main/java/com/memesphere/domain/notification/service/PushNotificationServiceImpl.java +++ b/src/main/java/com/memesphere/domain/notification/service/PushNotificationServiceImpl.java @@ -11,6 +11,8 @@ import com.memesphere.global.apipayload.ApiResponse; import com.memesphere.global.apipayload.code.status.ErrorStatus; import com.memesphere.global.apipayload.exception.GeneralException; +import com.memesphere.global.jwt.CustomUserDetails; +import com.memesphere.global.jwt.LoggedInUserStore; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.data.domain.PageRequest; @@ -24,6 +26,8 @@ import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.stream.Collectors; @Log4j2 @@ -34,6 +38,8 @@ public class PushNotificationServiceImpl implements PushNotificationService { private final EmitterRepository emitterRepository; private final NotificationRepository notificationRepository; private final ChartDataRepository chartDataRepository; + private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + private final LoggedInUserStore loggedInUserStore; // 연결 지속 시간 설정 : 한시간 private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; @@ -44,11 +50,19 @@ public SseEmitter subscribe(Long userId, String lastEventId) { // 고유한 아이디 생성 String emitterId = userId + "_" + System.currentTimeMillis(); // 사용자 id + 현재 시간을 밀리초 단위의 long값 SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT)); + loggedInUserStore.addUser(userId); // 클라이언트가 SSE 연결을 종료하면 실행됨 - emitter.onCompletion(() -> emitterRepository.deleteById(emitterId)); + emitter.onCompletion(() -> { + emitterRepository.deleteById(emitterId); + loggedInUserStore.removeUser(userId); + + }); // 지정된 시간이 지나거나 클라이언트가 요청을 안하면 실행됨 - emitter.onTimeout(() -> emitterRepository.deleteById(emitterId)); + emitter.onTimeout(() -> { + emitterRepository.deleteById(emitterId); + loggedInUserStore.removeUser(userId); + }); // 최초 연결 더미데이터가 없으면 503 에러가 나므로 더미 데이터 생성 sendToClient(emitter, emitterId, "EventStream Created. [userId=" + userId + "]"); @@ -61,18 +75,6 @@ public SseEmitter subscribe(Long userId, String lastEventId) { .forEach(entry -> sendToClient(emitter, entry.getKey(), entry.getValue())); } - List notifications = notificationRepository.findAllByUserId(userId); // 사용자가 등록한 알림 전부 가져오기 - - // 변동성을 초과하는 알림 필터링 - List filteredNotifications = notifications.stream() - .filter(notification -> isVolatilityExceeded(notification)) - .collect(Collectors.toList()); - - if (!filteredNotifications.isEmpty()) { - notifications.forEach(notification -> { - send(notification, userId); - }); - } return emitter; } @@ -91,15 +93,34 @@ private void sendToClient(SseEmitter emitter, String emitterId, Object data) { } @Override - public void send(Notification notification, Long userId) { + public void send(Long userId) { + List notifications = notificationRepository.findAllByUserId(userId); // 사용자가 등록한 알림 전부 가져오기 - // 실시간 알림 전송 - 로그인 한 유저의 SseEmitter 모두 가져오기 - Map sseEmitters = emitterRepository.findAllEmitterStartWithByUserId(String.valueOf(userId)); + // 변동성을 초과하는 알림 필터링 + List filteredNotifications = notifications.stream() + .filter(notification -> isVolatilityExceeded(notification)) + .collect(Collectors.toList()); - sseEmitters.forEach((key, emitter) -> { - emitterRepository.saveEventCache(key, notification); - sendToClient(emitter, key, NotificationConverter.toNotificationCreateResponse(notification, notification.getMemeCoin())); - }); + if (!filteredNotifications.isEmpty()) { + // 실시간 알림 전송 - 로그인 한 유저의 SseEmitter 모두 가져오기 + Map sseEmitters = emitterRepository.findAllEmitterStartWithByUserId(String.valueOf(userId)); + + sseEmitters.forEach((key, emitter) -> { + executorService.submit(() -> { + filteredNotifications.forEach(notification -> { + + emitterRepository.saveEventCache(key, notification); + sendToClient(emitter, key, NotificationConverter.toNotificationCreateResponse(notification, notification.getMemeCoin())); + + try { + Thread.sleep(500); // 0.5초 간격으로 전송 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + }); + }); + } } private boolean isVolatilityExceeded(Notification notification) { @@ -113,18 +134,20 @@ private boolean isVolatilityExceeded(Notification notification) { throw new GeneralException(ErrorStatus.CANNOT_LOAD_CHARTDATA); } + LocalDateTime notificationTime = notification.getCreatedAt(); + Integer count = notification.getStTime() / 10; //몇 번 가져올 것인지 결정 Pageable pageable = (Pageable) PageRequest.of(0, count, Sort.by(Sort.Direction.DESC, "createdAt")); - List lastNData = chartDataRepository.findByMemeCoinOrderByRecordedTimeDesc(memeCoin, pageable); + List lastNData = chartDataRepository.findByMemeCoinAndRecordedTimeAfterOrderByRecordedTimeDesc(memeCoin, notificationTime, pageable); if (lastNData.size() < count) { return false; // 비교할 데이터가 부족하면 알림을 보내지 않음 } BigDecimal sum = lastNData.stream() - .map(ChartData::getPrice) + .map(ChartData::getPriceChangeRate) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal average = sum.divide(BigDecimal.valueOf(lastNData.size()), 4, RoundingMode.HALF_UP); + BigDecimal average = sum.divide(BigDecimal.valueOf(count), 4, RoundingMode.HALF_UP); BigDecimal definedVolatility = new BigDecimal(notification.getVolatility()); if (notification.getIsRising()) { // 상승인 경우 @@ -133,5 +156,4 @@ private boolean isVolatilityExceeded(Notification notification) { return average.compareTo(definedVolatility) < 0; } } - } diff --git a/src/main/java/com/memesphere/domain/search/converter/SearchConverter.java b/src/main/java/com/memesphere/domain/search/converter/SearchConverter.java index bc1385dd..e3a16a54 100644 --- a/src/main/java/com/memesphere/domain/search/converter/SearchConverter.java +++ b/src/main/java/com/memesphere/domain/search/converter/SearchConverter.java @@ -62,7 +62,7 @@ public static SearchListPreviewResponse toSearchListPreviewDTO(MemeCoin memeCoin .name(memeCoin.getName()) .symbol(memeCoin.getSymbol()) .currentPrice(chartData.getPrice()) - .priceChange(chartData.getPriceChangeRate()) + .priceChangeRate(chartData.getPriceChangeRate()) .weightedAveragePrice(chartData.getWeighted_average_price()) .volume(chartData.getVolume()) .isCollected(isCollected) diff --git a/src/main/java/com/memesphere/domain/search/dto/response/SearchListPreviewResponse.java b/src/main/java/com/memesphere/domain/search/dto/response/SearchListPreviewResponse.java index 10367123..c9498ace 100644 --- a/src/main/java/com/memesphere/domain/search/dto/response/SearchListPreviewResponse.java +++ b/src/main/java/com/memesphere/domain/search/dto/response/SearchListPreviewResponse.java @@ -19,15 +19,12 @@ public class SearchListPreviewResponse { String name; @Schema(description = "밈코인 symbol", example = "DOGE") String symbol; - // market cap = currentPrice x volume @Schema(description = "차트 데이터의 price", example = "2000") BigDecimal currentPrice; @Schema(description = "차트 데이터의 weighted average price", example = "10000") BigDecimal weightedAveragePrice; @Schema(description = "차트 데이터의 volume", example = "5") BigDecimal volume; - @Schema(description = "차트 데이터의 price_change", example = "500") - BigDecimal priceChange; @Schema(description = "차트 데이터의 price_change_rate", example = "+2.4%") BigDecimal priceChangeRate; @Schema(description = "collection에 해당 밈코인 유무", example = "true / false") diff --git a/src/main/java/com/memesphere/domain/user/controller/UserController.java b/src/main/java/com/memesphere/domain/user/controller/UserController.java index 5ed37b30..8896cd3b 100644 --- a/src/main/java/com/memesphere/domain/user/controller/UserController.java +++ b/src/main/java/com/memesphere/domain/user/controller/UserController.java @@ -1,17 +1,9 @@ package com.memesphere.domain.user.controller; -import com.memesphere.domain.user.dto.request.ReissueRequest; -import com.memesphere.domain.user.dto.request.NicknameRequest; -import com.memesphere.domain.user.dto.request.SignInRequest; -import com.memesphere.domain.user.dto.request.SignUpRequest; -import com.memesphere.domain.user.dto.response.GoogleUserInfoResponse; -import com.memesphere.domain.user.dto.response.TokenResponse; -import com.memesphere.domain.user.dto.response.KakaoUserInfoResponse; -import com.memesphere.domain.user.service.AuthServiceImpl; -import com.memesphere.domain.user.service.GoogleServiceImpl; -import com.memesphere.domain.user.service.KakaoServiceImpl; +import com.memesphere.domain.user.dto.request.*; +import com.memesphere.domain.user.dto.response.*; +import com.memesphere.domain.user.service.*; import com.memesphere.global.apipayload.ApiResponse; -import com.memesphere.domain.user.dto.response.LoginResponse; import com.memesphere.global.apipayload.code.status.ErrorStatus; import com.memesphere.global.apipayload.exception.GeneralException; import com.memesphere.global.jwt.CustomUserDetails; @@ -35,6 +27,7 @@ public class UserController { private final KakaoServiceImpl kakaoServiceImpl; private final GoogleServiceImpl googleServiceImpl; private final AuthServiceImpl authServiceImpl; + private final MailServiceImpl mailServiceImpl; private final JwtAuthenticationFilter jwtAuthenticationFilter; @PostMapping("/login/oauth2/kakao") @@ -115,4 +108,30 @@ public ApiResponse isNicknameValidate(@RequestBody NicknameRequest nicknameRe return ApiResponse.onSuccess("사용 가능한 닉네임입니다."); } } + + @PostMapping("/password/send") + @Operation(summary = "비밀번호 찾기 API") + public ApiResponse sendPassword(@RequestParam("email") String email) { + // 임시 비밀번호 생성 및 저장 + String tmpPassword = authServiceImpl.getTmpPassword(); + authServiceImpl.updatePassword(tmpPassword, email); + + // 메일 생성 및 전송 + EmailResponse mailResponse = mailServiceImpl.createMail(tmpPassword, email); + mailServiceImpl.sendMail(mailResponse); + + return ApiResponse.onSuccess("이메일 전송이 완료되었습니다."); + } + + @PostMapping("/password/change") + @Operation(summary = "비밀번호 변경 API") + public ApiResponse sendPassword(@RequestParam("newPassword") String newPassword, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + if (customUserDetails == null) { + throw new GeneralException(ErrorStatus.USER_NOT_FOUND); + } + + authServiceImpl.updatePassword(newPassword, customUserDetails.getUser().getEmail()); + + return ApiResponse.onSuccess("비밀번호 변경이 완료되었습니다."); + } } diff --git a/src/main/java/com/memesphere/domain/user/converter/UserConverter.java b/src/main/java/com/memesphere/domain/user/converter/UserConverter.java index 9c723807..0e101d4b 100644 --- a/src/main/java/com/memesphere/domain/user/converter/UserConverter.java +++ b/src/main/java/com/memesphere/domain/user/converter/UserConverter.java @@ -1,6 +1,7 @@ package com.memesphere.domain.user.converter; import com.memesphere.domain.user.dto.request.SignUpRequest; +import com.memesphere.domain.user.dto.response.EmailResponse; import com.memesphere.domain.user.dto.response.GoogleUserInfoResponse; import com.memesphere.domain.user.entity.SocialType; import com.memesphere.domain.user.entity.User; @@ -49,5 +50,14 @@ public static User toAuthUser(SignUpRequest signUpRequest, PasswordEncoder passw .build(); } + // 비밀번호 찾기 이메일 + public static EmailResponse toEmailResponse(String tmpPassword, String memberEmail, String title, String message, String fromAddress) { + return EmailResponse.builder() + .toAddress(memberEmail) + .title(title) + .message(message + tmpPassword) + .fromAddress(fromAddress) + .build(); + } } diff --git a/src/main/java/com/memesphere/domain/user/dto/request/SignUpRequest.java b/src/main/java/com/memesphere/domain/user/dto/request/SignUpRequest.java index d2283f35..1877cc1a 100644 --- a/src/main/java/com/memesphere/domain/user/dto/request/SignUpRequest.java +++ b/src/main/java/com/memesphere/domain/user/dto/request/SignUpRequest.java @@ -30,6 +30,7 @@ public class SignUpRequest { @Schema(description = "사용자 생년월일", example = "20001010") String birth; + @NotEmpty @Schema(description = "프로필 이미지", example = "http://umc..jpg") String profileImage; } \ No newline at end of file diff --git a/src/main/java/com/memesphere/domain/user/dto/response/EmailResponse.java b/src/main/java/com/memesphere/domain/user/dto/response/EmailResponse.java new file mode 100644 index 00000000..87c04fa7 --- /dev/null +++ b/src/main/java/com/memesphere/domain/user/dto/response/EmailResponse.java @@ -0,0 +1,17 @@ +package com.memesphere.domain.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class EmailResponse { + private String toAddress; // 받는 이메일 주소 + private String title; // 이메일 제목 + private String message; // 이메일 내용 + private String fromAddress; // 보내는 이메일 주소 +} diff --git a/src/main/java/com/memesphere/domain/user/entity/User.java b/src/main/java/com/memesphere/domain/user/entity/User.java index a1a1024c..3287d4a7 100644 --- a/src/main/java/com/memesphere/domain/user/entity/User.java +++ b/src/main/java/com/memesphere/domain/user/entity/User.java @@ -67,4 +67,8 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) @Builder.Default private List chatLikeList = new ArrayList<>(); + + public void updatePassword(String password){ + this.password = password; + } } diff --git a/src/main/java/com/memesphere/domain/user/service/AuthService.java b/src/main/java/com/memesphere/domain/user/service/AuthService.java index 0a87b0d4..3053ce2e 100644 --- a/src/main/java/com/memesphere/domain/user/service/AuthService.java +++ b/src/main/java/com/memesphere/domain/user/service/AuthService.java @@ -12,4 +12,6 @@ public interface AuthService { LoginResponse reissueAccessToken(String refreshToken, User existingUser); void checkPassword(User user, String password); boolean checkNicknameDuplicate(String nickname); + String getTmpPassword(); + void updatePassword(String tmpPassword, String memberEmail); } diff --git a/src/main/java/com/memesphere/domain/user/service/AuthServiceImpl.java b/src/main/java/com/memesphere/domain/user/service/AuthServiceImpl.java index 49762452..872f06db 100644 --- a/src/main/java/com/memesphere/domain/user/service/AuthServiceImpl.java +++ b/src/main/java/com/memesphere/domain/user/service/AuthServiceImpl.java @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -22,7 +23,6 @@ public class AuthServiceImpl implements AuthService{ private final PasswordEncoder passwordEncoder; private final UserRepository userRepository; - private final UserServiceImpl userServiceImpl; private final TokenProvider tokenProvider; private final RedisService redisService; @@ -38,7 +38,7 @@ public void handleUserRegistration(SignUpRequest signUpRequest) { } User newUser = UserConverter.toAuthUser(signUpRequest, passwordEncoder); - userServiceImpl.save(newUser); + userRepository.save(newUser); } public LoginResponse handleUserLogin(SignInRequest signInRequest) { @@ -111,4 +111,29 @@ public void checkPassword(User user, String password) { public boolean checkNicknameDuplicate(String nickname) { return userRepository.findByNickname(nickname).isPresent(); } + + public String getTmpPassword() { + char[] charSet = new char[]{ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', + 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'}; + + String pwd = ""; + + // 문자 배열 길이의 값을 랜덤으로 10개를 뽑아 조합 + int idx = 0; + for(int i = 0; i < 10; i++){ + idx = (int) (charSet.length * Math.random()); + pwd += charSet[idx]; + } + + return pwd; + } + + @Transactional + public void updatePassword(String tmpPassword, String memberEmail) { + String encryptPassword = passwordEncoder.encode(tmpPassword); + User existingUser = userRepository.findByEmail(memberEmail).orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + existingUser.updatePassword(encryptPassword); + } } diff --git a/src/main/java/com/memesphere/domain/user/service/GoogleServiceImpl.java b/src/main/java/com/memesphere/domain/user/service/GoogleServiceImpl.java index a25c2711..9f3d84ca 100644 --- a/src/main/java/com/memesphere/domain/user/service/GoogleServiceImpl.java +++ b/src/main/java/com/memesphere/domain/user/service/GoogleServiceImpl.java @@ -24,7 +24,6 @@ public class GoogleServiceImpl implements GoogleService{ private final TokenProvider tokenProvider; - private final UserServiceImpl userServiceImpl; private final UserRepository userRepository; @Value("${security.oauth2.client.registration.google.client-id}") diff --git a/src/main/java/com/memesphere/domain/user/service/KakaoServiceImpl.java b/src/main/java/com/memesphere/domain/user/service/KakaoServiceImpl.java index a69a28dd..d7857d9a 100644 --- a/src/main/java/com/memesphere/domain/user/service/KakaoServiceImpl.java +++ b/src/main/java/com/memesphere/domain/user/service/KakaoServiceImpl.java @@ -22,7 +22,6 @@ public class KakaoServiceImpl implements KakaoService { private final TokenProvider tokenProvider; - private final UserServiceImpl userServiceImpl; private final UserRepository userRepository; private final RedisService redisService; @@ -70,7 +69,7 @@ public KakaoUserInfoResponse getUserInfo(String accessToken) { } public LoginResponse handleUserLogin(KakaoUserInfoResponse kakaoUserInfoResponse) { - User existingUser = userServiceImpl.findByLoginId(kakaoUserInfoResponse.getId()); + User existingUser = userRepository.findByLoginId(kakaoUserInfoResponse.getId()).orElse(null); String accessToken; if (existingUser != null) { diff --git a/src/main/java/com/memesphere/domain/user/service/MailService.java b/src/main/java/com/memesphere/domain/user/service/MailService.java new file mode 100644 index 00000000..f9fbeb28 --- /dev/null +++ b/src/main/java/com/memesphere/domain/user/service/MailService.java @@ -0,0 +1,8 @@ +package com.memesphere.domain.user.service; + +import com.memesphere.domain.user.dto.response.EmailResponse; + +public interface MailService { + EmailResponse createMail(String tmpPassword, String memberEmail); + void sendMail(EmailResponse email); +} diff --git a/src/main/java/com/memesphere/domain/user/service/MailServiceImpl.java b/src/main/java/com/memesphere/domain/user/service/MailServiceImpl.java new file mode 100644 index 00000000..3f9a4cdb --- /dev/null +++ b/src/main/java/com/memesphere/domain/user/service/MailServiceImpl.java @@ -0,0 +1,49 @@ +package com.memesphere.domain.user.service; + +import com.memesphere.domain.user.converter.UserConverter; +import com.memesphere.domain.user.dto.response.EmailResponse; +import com.memesphere.domain.user.entity.User; +import com.memesphere.domain.user.repository.UserRepository; +import com.memesphere.global.apipayload.code.status.ErrorStatus; +import com.memesphere.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MailServiceImpl implements MailService { + + private final JavaMailSender mailSender; + private final UserRepository userRepository; + + private static final String title = "MemeSphere 임시 비밀번호 안내 이메일입니다."; + private static final String message = "안녕하세요. MemeSphere 임시 비밀번호 안내 메일입니다. " + +"\n" + "회원님의 임시 비밀번호는 아래와 같습니다. 로그인 후 반드시 비밀번호를 변경해주세요."+"\n"; + private static final String fromAddress = "memesphere01@gmail.com"; + + @Override + public EmailResponse createMail(String tmpPassword, String memberEmail) { + User existingUser = userRepository.findByEmail(memberEmail).orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + String password = existingUser.getPassword(); + + if (password == null) { + throw new GeneralException(ErrorStatus.SOCIAL_LOGIN_NOT_ALLOWED); + } + + return UserConverter.toEmailResponse(tmpPassword, memberEmail, title, message, fromAddress); + } + + @Override + public void sendMail(EmailResponse email) { + SimpleMailMessage mailMessage = new SimpleMailMessage(); + mailMessage.setTo(email.getToAddress()); + mailMessage.setSubject(email.getTitle()); + mailMessage.setText(email.getMessage()); + mailMessage.setFrom(email.getFromAddress()); + mailMessage.setReplyTo(email.getFromAddress()); + + mailSender.send(mailMessage); + } +} diff --git a/src/main/java/com/memesphere/domain/user/service/UserService.java b/src/main/java/com/memesphere/domain/user/service/UserService.java deleted file mode 100644 index 6ac9ed24..00000000 --- a/src/main/java/com/memesphere/domain/user/service/UserService.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.memesphere.domain.user.service; - -import com.memesphere.domain.user.entity.User; - -public interface UserService { - User findByLoginId(Long loginId); - void save(User user); -} diff --git a/src/main/java/com/memesphere/domain/user/service/UserServiceImpl.java b/src/main/java/com/memesphere/domain/user/service/UserServiceImpl.java deleted file mode 100644 index 977a7f27..00000000 --- a/src/main/java/com/memesphere/domain/user/service/UserServiceImpl.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.memesphere.domain.user.service; - -import com.memesphere.domain.user.entity.User; -import com.memesphere.domain.user.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserServiceImpl implements UserService { - - private final UserRepository userRepository; - - public User findByLoginId(Long loginId) { - return userRepository.findByLoginId(loginId).orElse(null); - } - - public void save(User user){ - userRepository.save(user); - } -} \ No newline at end of file diff --git a/src/main/java/com/memesphere/global/apipayload/code/status/ErrorStatus.java b/src/main/java/com/memesphere/global/apipayload/code/status/ErrorStatus.java index d7ebc7de..94b0553d 100644 --- a/src/main/java/com/memesphere/global/apipayload/code/status/ErrorStatus.java +++ b/src/main/java/com/memesphere/global/apipayload/code/status/ErrorStatus.java @@ -31,6 +31,7 @@ public enum ErrorStatus implements BaseCode { // ChartData load 에러 CANNOT_LOAD_CHARTDATA(HttpStatus.BAD_REQUEST, "CANNOT LOAD CHARTDATA", "ChartData를 Binance에서 로드할 수 없습니다."), + CHARTDATA_NOT_FOUND(HttpStatus.NOT_FOUND, "CHARTDATA NOT FOUND", "차트 데이터를 찾을 수 없습니다."), // notification 에러 CANNOT_CHECK_VOLATILITY(HttpStatus.NOT_FOUND, "CANNOT CHECK VOLATILITY", "변동성을 확인할 수 없습니다."), @@ -48,13 +49,18 @@ public enum ErrorStatus implements BaseCode { EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "EXPIRED TOKEN", "만료된 토큰입니다."), UNSUPPORTED_TOKEN(HttpStatus.UNAUTHORIZED, "UNSUPPORTED TOKEN", "지원하지 않는 토큰입니다."), INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "INVALID SIGNATURE", "잘못된 JWT 서명입니다"), + SOCIAL_LOGIN_NOT_ALLOWED(HttpStatus.FORBIDDEN, "SOCIAL LOGIN NOT ALLOWED", "소셜 로그인 계정으로 비밀번호를 찾을 수 없습니다."), // 이미지 에러 INVALID_FILE_EXTENTION(HttpStatus.BAD_REQUEST, "INVALID FILE EXTENSION", "지원되지 않는 파일 형식입니다."), PRESIGNED_URL_FAILED(HttpStatus.BAD_REQUEST, "PRESIGNED URL GENERATION FAILED", "presigned URL 생성에 실패했습니다."), // 채팅 에러 - CHAT_NOT_FOUND(HttpStatus.NOT_FOUND, "CHAT NOT FOUND", "채팅을 찾을 수 없습니다."); + CHAT_NOT_FOUND(HttpStatus.NOT_FOUND, "CHAT NOT FOUND", "채팅을 찾을 수 없습니다."), + + // 네이버 api 에러 + KEY_UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"NAVER 401 ERROR", "인증 실패. 클라이언트 ID 또는 시크릿이 올바르지 않습니다."), + API_FORBIDDEN(HttpStatus.FORBIDDEN, "NAVER 403 ERROR","API 호출 횟수를 초과했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/memesphere/global/jwt/LoggedInUserStore.java b/src/main/java/com/memesphere/global/jwt/LoggedInUserStore.java new file mode 100644 index 00000000..2dde4f7c --- /dev/null +++ b/src/main/java/com/memesphere/global/jwt/LoggedInUserStore.java @@ -0,0 +1,27 @@ +package com.memesphere.global.jwt; + +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class LoggedInUserStore { + private final Set loggedInUsers = ConcurrentHashMap.newKeySet(); + + public void addUser(Long userId) { + loggedInUsers.add(userId); + } + + public void removeUser(Long userId) { + loggedInUsers.remove(userId); + } + + public boolean isUserLoggedIn(Long userId) { + return loggedInUsers.contains(userId); + } + + public Set getLoggedInUsers() { + return loggedInUsers; + } +}