diff --git a/api/src/main/java/com/chaegangjo/goods/application/SaveGoodsUseCase.java b/api/src/main/java/com/chaegangjo/goods/application/SaveGoodsUseCase.java index ae4057b..1cf75fa 100644 --- a/api/src/main/java/com/chaegangjo/goods/application/SaveGoodsUseCase.java +++ b/api/src/main/java/com/chaegangjo/goods/application/SaveGoodsUseCase.java @@ -12,6 +12,7 @@ import com.chaegangjo.goods.service.GoodsImageService; import com.chaegangjo.goods.service.GoodsService; import com.chaegangjo.member.domain.Member; +import com.chaegangjo.member.service.KeywordNotificationService; import com.chaegangjo.member.service.MemberService; import java.util.List; import lombok.RequiredArgsConstructor; @@ -28,6 +29,7 @@ public class SaveGoodsUseCase { private final GoodsImageService goodsImageService; private final CategoryService categoryService; private final ChatMemberService chatMemberService; + private final KeywordNotificationService keywordNotificationService; @Transactional public DetailGoodsInfo execute(SaveGoodsRequest request, Long sellerId) { @@ -38,6 +40,8 @@ public DetailGoodsInfo execute(SaveGoodsRequest request, Long sellerId) { chatMemberService.saveChatMember(goods, seller, request.getMyQuantity(), SELLER); List goodsImages = goodsImageService.saveGoodsImages(goods, request.imageNames().subList(1, request.imageNames().size())); + keywordNotificationService.notifyForNewGoods(goods); + return DetailGoodsInfo.of(goods, goodsImages); } } diff --git a/api/src/main/java/com/chaegangjo/member/appllication/DeleteMyKeywordNotificationUseCase.java b/api/src/main/java/com/chaegangjo/member/appllication/DeleteMyKeywordNotificationUseCase.java new file mode 100644 index 0000000..a552cbf --- /dev/null +++ b/api/src/main/java/com/chaegangjo/member/appllication/DeleteMyKeywordNotificationUseCase.java @@ -0,0 +1,21 @@ +package com.chaegangjo.member.appllication; + +import com.chaegangjo.member.dto.KeywordNotificationInfo; +import com.chaegangjo.member.service.KeywordNotificationService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class DeleteMyKeywordNotificationUseCase { + + private final KeywordNotificationService keywordNotificationService; + + public List execute(Long memberId, Long keywordNotificationId) { + keywordNotificationService.delete(memberId, keywordNotificationId); + return keywordNotificationService.findAllByMemberId(memberId).stream() + .map(KeywordNotificationInfo::from) + .toList(); + } +} diff --git a/api/src/main/java/com/chaegangjo/member/appllication/GetMyKeywordNotificationsUseCase.java b/api/src/main/java/com/chaegangjo/member/appllication/GetMyKeywordNotificationsUseCase.java new file mode 100644 index 0000000..5a62f51 --- /dev/null +++ b/api/src/main/java/com/chaegangjo/member/appllication/GetMyKeywordNotificationsUseCase.java @@ -0,0 +1,20 @@ +package com.chaegangjo.member.appllication; + +import com.chaegangjo.member.dto.KeywordNotificationInfo; +import com.chaegangjo.member.service.KeywordNotificationService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class GetMyKeywordNotificationsUseCase { + + private final KeywordNotificationService keywordNotificationService; + + public List execute(Long memberId) { + return keywordNotificationService.findAllByMemberId(memberId).stream() + .map(KeywordNotificationInfo::from) + .toList(); + } +} diff --git a/api/src/main/java/com/chaegangjo/member/appllication/RegisterKeywordNotificationUseCase.java b/api/src/main/java/com/chaegangjo/member/appllication/RegisterKeywordNotificationUseCase.java new file mode 100644 index 0000000..bc0961c --- /dev/null +++ b/api/src/main/java/com/chaegangjo/member/appllication/RegisterKeywordNotificationUseCase.java @@ -0,0 +1,24 @@ +package com.chaegangjo.member.appllication; + +import com.chaegangjo.member.domain.KeywordNotification; +import com.chaegangjo.member.domain.Member; +import com.chaegangjo.member.dto.KeywordNotificationInfo; +import com.chaegangjo.member.dto.request.RegisterKeywordRequest; +import com.chaegangjo.member.service.KeywordNotificationService; +import com.chaegangjo.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class RegisterKeywordNotificationUseCase { + + private final MemberService memberService; + private final KeywordNotificationService keywordNotificationService; + + public KeywordNotificationInfo execute(Long memberId, RegisterKeywordRequest request) { + Member member = memberService.findMemberById(memberId); + KeywordNotification keywordNotification = keywordNotificationService.register(member, request.keyword()); + return KeywordNotificationInfo.from(keywordNotification); + } +} diff --git a/api/src/main/java/com/chaegangjo/member/dto/KeywordNotificationInfo.java b/api/src/main/java/com/chaegangjo/member/dto/KeywordNotificationInfo.java new file mode 100644 index 0000000..53b03bc --- /dev/null +++ b/api/src/main/java/com/chaegangjo/member/dto/KeywordNotificationInfo.java @@ -0,0 +1,15 @@ +package com.chaegangjo.member.dto; + +import com.chaegangjo.member.domain.KeywordNotification; +import io.swagger.v3.oas.annotations.media.Schema; + +public record KeywordNotificationInfo( + @Schema(example = "1") + Long id, + @Schema(example = "생수") + String keyword +) { + public static KeywordNotificationInfo from(KeywordNotification keywordNotification) { + return new KeywordNotificationInfo(keywordNotification.getId(), keywordNotification.getKeyword()); + } +} diff --git a/api/src/main/java/com/chaegangjo/member/dto/request/RegisterKeywordRequest.java b/api/src/main/java/com/chaegangjo/member/dto/request/RegisterKeywordRequest.java new file mode 100644 index 0000000..db7abfd --- /dev/null +++ b/api/src/main/java/com/chaegangjo/member/dto/request/RegisterKeywordRequest.java @@ -0,0 +1,8 @@ +package com.chaegangjo.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record RegisterKeywordRequest( + @Schema(example = "생수") + String keyword) { +} diff --git a/api/src/main/java/com/chaegangjo/member/prensentation/MemberController.java b/api/src/main/java/com/chaegangjo/member/prensentation/MemberController.java index d3b172a..2056072 100644 --- a/api/src/main/java/com/chaegangjo/member/prensentation/MemberController.java +++ b/api/src/main/java/com/chaegangjo/member/prensentation/MemberController.java @@ -6,9 +6,11 @@ import com.chaegangjo.goods.dto.GoodsInfo; import com.chaegangjo.goods.enums.TradeStatus; import com.chaegangjo.member.appllication.*; +import com.chaegangjo.member.dto.KeywordNotificationInfo; import com.chaegangjo.member.dto.MemberInfo; import com.chaegangjo.member.dto.NotificationHistoryInfo; import com.chaegangjo.member.dto.SearchHistoryInfo; +import com.chaegangjo.member.dto.request.RegisterKeywordRequest; import com.chaegangjo.member.dto.request.SetNeighborhoodRequest; import com.chaegangjo.security.oauth2.entity.CustomOAuth2User; import io.swagger.v3.oas.annotations.Operation; @@ -34,6 +36,9 @@ public class MemberController { private final GetMySearchHistoriesUseCase getMySearchHistoriesUseCase; private final GetMyNotificationHistoriesUseCase getMyNotificationHistoriesUseCase; private final DeleteMySearchHistoryUseCase deleteMySearchHistoryUseCase; + private final RegisterKeywordNotificationUseCase registerKeywordNotificationUseCase; + private final GetMyKeywordNotificationsUseCase getMyKeywordNotificationsUseCase; + private final DeleteMyKeywordNotificationUseCase deleteMyKeywordNotificationUseCase; @Operation(summary = "회원 정보 조회") // @PreAuthorize("#id == principal.id") @@ -150,4 +155,34 @@ public ResponseEntity>> deleteMySearchHistor @AuthenticationPrincipal CustomOAuth2User user) { return ResponseEntity.ok(ApiResponse.success(deleteMySearchHistoryUseCase.all(user.getId()))); } + + @Operation(summary = "키워드 알림 등록", description = "관심 키워드를 등록하면 해당 키워드와 일치하는 상품이 근처에 등록될 때 알림을 받습니다.") + @PostMapping("/me/keywords") + public ResponseEntity> registerKeyword( + @RequestBody RegisterKeywordRequest request, + @AuthenticationPrincipal CustomOAuth2User user) { + return ResponseEntity.ok( + ApiResponse.success(registerKeywordNotificationUseCase.execute(user.getId(), request)) + ); + } + + @Operation(summary = "나의 키워드 알림 목록 조회") + @GetMapping("/me/keywords") + public ResponseEntity>> getMyKeywords( + @AuthenticationPrincipal CustomOAuth2User user) { + return ResponseEntity.ok( + ApiResponse.success(getMyKeywordNotificationsUseCase.execute(user.getId())) + ); + } + + @Operation(summary = "키워드 알림 삭제") + @DeleteMapping("/me/keywords/{keywordNotificationId}") + public ResponseEntity>> deleteMyKeyword( + @Parameter(example = "1") + @PathVariable Long keywordNotificationId, + @AuthenticationPrincipal CustomOAuth2User user) { + return ResponseEntity.ok( + ApiResponse.success(deleteMyKeywordNotificationUseCase.execute(user.getId(), keywordNotificationId)) + ); + } } \ No newline at end of file diff --git a/common/src/main/java/com/chaegangjo/exception/KeywordNotificationException.java b/common/src/main/java/com/chaegangjo/exception/KeywordNotificationException.java new file mode 100644 index 0000000..49413cf --- /dev/null +++ b/common/src/main/java/com/chaegangjo/exception/KeywordNotificationException.java @@ -0,0 +1,11 @@ +package com.chaegangjo.exception; + + +import com.chaegangjo.exception.errorcode.KeywordNotificationErrorCode; + +public class KeywordNotificationException extends BaseException { + + public KeywordNotificationException(KeywordNotificationErrorCode errorCode) { + super(errorCode); + } +} diff --git a/common/src/main/java/com/chaegangjo/exception/errorcode/KeywordNotificationErrorCode.java b/common/src/main/java/com/chaegangjo/exception/errorcode/KeywordNotificationErrorCode.java new file mode 100644 index 0000000..a19332f --- /dev/null +++ b/common/src/main/java/com/chaegangjo/exception/errorcode/KeywordNotificationErrorCode.java @@ -0,0 +1,22 @@ +package com.chaegangjo.exception.errorcode; + + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum KeywordNotificationErrorCode implements BaseErrorCode { + + KEYWORD_NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "KEYWORD-NOTIFICATION-001", "존재하지 않는 키워드 알림입니다."), + OWNER_MISMATCH(HttpStatus.BAD_REQUEST, "KEYWORD-NOTIFICATION-002", "본인의 키워드 알림만 삭제할 수 있습니다."), + BLANK_KEYWORD(HttpStatus.BAD_REQUEST, "KEYWORD-NOTIFICATION-003", "키워드는 공백일 수 없습니다."), + DUPLICATE_KEYWORD(HttpStatus.CONFLICT, "KEYWORD-NOTIFICATION-004", "이미 등록된 키워드입니다."), + KEYWORD_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "KEYWORD-NOTIFICATION-005", "등록 가능한 키워드 개수를 초과했습니다.") + ; + + private final HttpStatus status; + private final String value; + private final String message; +} diff --git a/common/src/main/java/com/chaegangjo/redis/RedisUtil.java b/common/src/main/java/com/chaegangjo/redis/RedisUtil.java index 94ac412..40f0ee8 100644 --- a/common/src/main/java/com/chaegangjo/redis/RedisUtil.java +++ b/common/src/main/java/com/chaegangjo/redis/RedisUtil.java @@ -104,6 +104,28 @@ public List getNearByIds(Long memberId, int restrictDistance, Long selecte return nearByIds; } + public List getNearByMemberIds(Point center, int restrictDistance) { + GeoReference reference = GeoReference.fromCoordinate(center); + Distance radius = new Distance(restrictDistance, Metrics.METERS); + + RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs + .newGeoRadiusArgs() + .includeDistance() + .sortAscending(); + + GeoOperations geoOperations = redisTemplate.opsForGeo(); + GeoResults> results = geoOperations + .search(MEMBER_KEY, reference, radius, args); + + List nearByMemberIds = new ArrayList<>(); + for (GeoResult> result : results) { + RedisGeoCommands.GeoLocation location = result.getContent(); + nearByMemberIds.add(Long.parseLong(location.getName().toString())); + } + + return nearByMemberIds; + } + private GeoResults> getGeoResults(Long memberId, int restrictDistance) { Point memberPoint = getPoint(MEMBER_KEY, String.valueOf(memberId)); if (memberPoint == null) { diff --git a/core/src/main/java/com/chaegangjo/member/domain/KeywordNotification.java b/core/src/main/java/com/chaegangjo/member/domain/KeywordNotification.java new file mode 100644 index 0000000..d223aac --- /dev/null +++ b/core/src/main/java/com/chaegangjo/member/domain/KeywordNotification.java @@ -0,0 +1,39 @@ +package com.chaegangjo.member.domain; + +import com.chaegangjo.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "keyword"})) +public class KeywordNotification extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 30) + private String keyword; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id") + private Member member; + + public KeywordNotification(String keyword, Member member) { + this.keyword = keyword; + this.member = member; + } +} diff --git a/core/src/main/java/com/chaegangjo/member/repository/KeywordNotificationRepository.java b/core/src/main/java/com/chaegangjo/member/repository/KeywordNotificationRepository.java new file mode 100644 index 0000000..41be43b --- /dev/null +++ b/core/src/main/java/com/chaegangjo/member/repository/KeywordNotificationRepository.java @@ -0,0 +1,20 @@ +package com.chaegangjo.member.repository; + +import com.chaegangjo.member.domain.KeywordNotification; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface KeywordNotificationRepository extends JpaRepository { + + List findAllByMember_IdOrderByCreatedAtDesc(Long memberId); + + boolean existsByMember_IdAndKeyword(Long memberId, String keyword); + + long countByMember_Id(Long memberId); + + @Query("SELECT k FROM KeywordNotification k JOIN FETCH k.member " + + "WHERE :goodsName LIKE CONCAT('%', k.keyword, '%')") + List findAllMatchingGoodsName(@Param("goodsName") String goodsName); +} diff --git a/core/src/main/java/com/chaegangjo/member/service/KeywordNotificationService.java b/core/src/main/java/com/chaegangjo/member/service/KeywordNotificationService.java new file mode 100644 index 0000000..91d69fb --- /dev/null +++ b/core/src/main/java/com/chaegangjo/member/service/KeywordNotificationService.java @@ -0,0 +1,132 @@ +package com.chaegangjo.member.service; + +import static com.chaegangjo.exception.errorcode.KeywordNotificationErrorCode.BLANK_KEYWORD; +import static com.chaegangjo.exception.errorcode.KeywordNotificationErrorCode.DUPLICATE_KEYWORD; +import static com.chaegangjo.exception.errorcode.KeywordNotificationErrorCode.KEYWORD_LIMIT_EXCEEDED; +import static com.chaegangjo.exception.errorcode.KeywordNotificationErrorCode.KEYWORD_NOTIFICATION_NOT_FOUND; +import static com.chaegangjo.exception.errorcode.KeywordNotificationErrorCode.OWNER_MISMATCH; + +import com.chaegangjo.exception.KeywordNotificationException; +import com.chaegangjo.goods.domain.Goods; +import com.chaegangjo.member.domain.KeywordNotification; +import com.chaegangjo.member.domain.Member; +import com.chaegangjo.member.domain.Notification; +import com.chaegangjo.member.domain.NotificationHistory; +import com.chaegangjo.member.repository.KeywordNotificationRepository; +import com.chaegangjo.member.repository.NotificationHistoryRepository; +import com.chaegangjo.member.repository.NotificationRepository; +import com.chaegangjo.redis.RedisUtil; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.geo.Point; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class KeywordNotificationService { + + private final KeywordNotificationRepository keywordNotificationRepository; + private final NotificationRepository notificationRepository; + private final NotificationHistoryRepository notificationHistoryRepository; + private final RedisUtil redisUtil; + + private static final int MAX_KEYWORDS_PER_MEMBER = 20; + private static final int RESTRICT_DISTANCE = 300000000; + private static final String NOTIFICATION_TITLE = "찾으시던 상품이 올라왔어요!"; + + public List findAllByMemberId(Long memberId) { + return keywordNotificationRepository.findAllByMember_IdOrderByCreatedAtDesc(memberId); + } + + @Transactional + public KeywordNotification register(Member member, String keyword) { + String normalized = normalize(keyword); + + if (keywordNotificationRepository.existsByMember_IdAndKeyword(member.getId(), normalized)) { + throw new KeywordNotificationException(DUPLICATE_KEYWORD); + } + if (keywordNotificationRepository.countByMember_Id(member.getId()) >= MAX_KEYWORDS_PER_MEMBER) { + throw new KeywordNotificationException(KEYWORD_LIMIT_EXCEEDED); + } + + return keywordNotificationRepository.save(new KeywordNotification(normalized, member)); + } + + @Transactional + public void delete(Long memberId, Long keywordNotificationId) { + KeywordNotification keywordNotification = keywordNotificationRepository.findById(keywordNotificationId) + .orElseThrow(() -> new KeywordNotificationException(KEYWORD_NOTIFICATION_NOT_FOUND)); + + if (!keywordNotification.getMember().getId().equals(memberId)) { + throw new KeywordNotificationException(OWNER_MISMATCH); + } + + keywordNotificationRepository.delete(keywordNotification); + } + + /** + * 신규 상품 등록 시, 상품명과 일치하는 키워드를 구독하고 상품 반경 내에 있는 회원에게 알림을 생성한다. + * 알림 생성 실패가 상품 등록 흐름을 막지 않도록 방어적으로 처리한다. + */ + @Transactional + public void notifyForNewGoods(Goods goods) { + try { + List matched = keywordNotificationRepository.findAllMatchingGoodsName(goods.getName()); + if (matched.isEmpty()) { + return; + } + + Set nearByMemberIds = findNearByMemberIds(goods); + if (nearByMemberIds.isEmpty()) { + return; + } + + Long sellerId = goods.getSeller().getId(); + + // 한 회원이 여러 키워드를 등록했을 수 있으므로 회원당 첫 매칭 키워드 하나만 사용한다. + Map targets = new LinkedHashMap<>(); + for (KeywordNotification keywordNotification : matched) { + Long memberId = keywordNotification.getMember().getId(); + if (memberId.equals(sellerId) || !nearByMemberIds.contains(memberId)) { + continue; + } + targets.putIfAbsent(memberId, keywordNotification); + } + + for (KeywordNotification target : targets.values()) { + Notification notification = notificationRepository.save( + new Notification(NOTIFICATION_TITLE, buildBody(target.getKeyword(), goods.getName()), goods)); + notificationHistoryRepository.save(new NotificationHistory(target.getMember(), notification)); + } + } catch (Exception e) { + log.warn("키워드 알림 생성 실패 - goodsId: {}", goods.getId(), e); + } + } + + private Set findNearByMemberIds(Goods goods) { + if (goods.getLongitude() == null || goods.getLatitude() == null) { + return Set.of(); + } + Point goodsPoint = new Point(goods.getLongitude(), goods.getLatitude()); + return new HashSet<>(redisUtil.getNearByMemberIds(goodsPoint, RESTRICT_DISTANCE)); + } + + private String buildBody(String keyword, String goodsName) { + return "'" + keyword + "' 키워드의 상품 '" + goodsName + "'이(가) 근처에 등록되었어요."; + } + + private String normalize(String keyword) { + if (keyword == null || keyword.isBlank()) { + throw new KeywordNotificationException(BLANK_KEYWORD); + } + return keyword.trim(); + } +} diff --git a/core/src/main/java/com/chaegangjo/product/service/ProductService.java b/core/src/main/java/com/chaegangjo/product/service/ProductService.java index fb5fba8..024869a 100644 --- a/core/src/main/java/com/chaegangjo/product/service/ProductService.java +++ b/core/src/main/java/com/chaegangjo/product/service/ProductService.java @@ -18,6 +18,8 @@ public class ProductService { public List findAll() { return productRepository.findAll(); } + + public List findByKeyword(String keyword) { return productRepository.findTop7ByNameContainingIgnoreCase(keyword); } }