diff --git a/Stoury-batch/src/test/groovy/com/learning/RedisTemplateTest.groovy b/Stoury-batch/src/test/groovy/com/learning/RedisTemplateTest.groovy new file mode 100644 index 0000000..33fed3c --- /dev/null +++ b/Stoury-batch/src/test/groovy/com/learning/RedisTemplateTest.groovy @@ -0,0 +1,49 @@ +package com.learning + +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.Cursor +import org.springframework.data.redis.core.ScanOptions +import org.springframework.data.redis.core.StringRedisTemplate +import spock.lang.Specification + +class RedisTemplateTest extends Specification { + def connectionFactory = new LettuceConnectionFactory("localhost", 8379) + def redisTemplate = new StringRedisTemplate(connectionFactory) + def testKey = "TestKey" + Cursor cursor + + def setup(){ + connectionFactory.start() + redisTemplate.delete(testKey) + } + + def cleanup() { + if (cursor != null && !cursor.isClosed()) { + cursor.close() + } + } + + def "opsForSet에 addAll() 가능한지"(){ + given: + def opsForSet = redisTemplate.opsForSet() + def list = [1L, 2L, 3L, 4L] + String[] stringArr = list.stream().map(String::valueOf).toArray() + when: + opsForSet.add(testKey, stringArr) + then: + opsForSet.members(testKey).size() == 4 + } + + def "SCAN 했는데 왜 key가 반환이 안되지"() { + given: + redisTemplate.opsForSet().add("FrequentTags:3333", "val1") + redisTemplate.opsForSet().add("FrequentTags:2222", "val1") + redisTemplate.opsForSet().add("FrequentTags:1113", "val1") + def scanOption = ScanOptions.scanOptions().match("FrequentTags:*").count(1).build() + cursor = redisTemplate.getConnectionFactory().getConnection().keyCommands().scan(scanOption) + expect: + cursor.hasNext() + cursor.hasNext() + cursor.hasNext() + } +} diff --git a/Stoury-batch/src/test/groovy/com/stoury/BatchTest.groovy b/Stoury-batch/src/test/groovy/com/stoury/BatchIntegrationTest.groovy similarity index 93% rename from Stoury-batch/src/test/groovy/com/stoury/BatchTest.groovy rename to Stoury-batch/src/test/groovy/com/stoury/BatchIntegrationTest.groovy index 884cb1a..a43e7aa 100644 --- a/Stoury-batch/src/test/groovy/com/stoury/BatchTest.groovy +++ b/Stoury-batch/src/test/groovy/com/stoury/BatchIntegrationTest.groovy @@ -1,15 +1,7 @@ package com.stoury -import com.stoury.domain.Diary -import com.stoury.domain.Feed -import com.stoury.domain.GraphicContent -import com.stoury.domain.Like -import com.stoury.domain.Member -import com.stoury.repository.DiaryRepository -import com.stoury.repository.FeedRepository -import com.stoury.repository.LikeRepository -import com.stoury.repository.MemberRepository -import com.stoury.repository.RankingRepository +import com.stoury.domain.* +import com.stoury.repository.* import com.stoury.utils.cachekeys.FeedLikesCountSnapshotKeys import com.stoury.utils.cachekeys.PopularSpotsKey import org.springframework.batch.core.Job @@ -24,14 +16,12 @@ import spock.lang.Specification import java.time.temporal.ChronoUnit -import static com.stoury.utils.cachekeys.HotFeedsKeys.DAILY_HOT_FEEDS -import static com.stoury.utils.cachekeys.HotFeedsKeys.MONTHLY_HOT_FEEDS -import static com.stoury.utils.cachekeys.HotFeedsKeys.WEEKLY_HOT_FEEDS +import static com.stoury.utils.cachekeys.HotFeedsKeys.* @SpringBootTest(classes = StouryBatchApplication.class) @SpringBatchTest @ActiveProfiles("test") -class BatchTest extends Specification { +class BatchIntegrationTest extends Specification { @Autowired JobLauncherTestUtils jobLauncherTestUtils; @Autowired @@ -55,6 +45,8 @@ class BatchTest extends Specification { LikeRepository likeRepository @Autowired DiaryRepository diaryRepository + @Autowired + TagRepository tagRepository @Autowired StringRedisTemplate redisTemplate @@ -62,7 +54,9 @@ class BatchTest extends Specification { def member = new Member("aaa@dddd.com", "qwdqwdqwd", "username", null) def setup() { + feedRepository.deleteAllFeedResponse() feedRepository.deleteAll() + tagRepository.deleteAll() diaryRepository.deleteAll() memberRepository.deleteAll() memberRepository.save(member) @@ -72,7 +66,9 @@ class BatchTest extends Specification { } def cleanup() { + feedRepository.deleteAllFeedResponse() feedRepository.deleteAll() + tagRepository.deleteAll() diaryRepository.deleteAll() memberRepository.deleteAll() @@ -178,10 +174,10 @@ class BatchTest extends Specification { likeRepository.getCountSnapshotByFeed(feed.id.toString(), ChronoUnit.YEARS) == 0) } - def feed(long num){ + def feed(long num) { def feed = Feed.builder() .member(member) - .textContent("feed"+num) + .textContent("feed" + num) .latitude(0) .longitude(0) .city("city" + num) diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 84d86f3..3cb14ed 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -12,6 +12,8 @@ include::GetFeedsByTag.adoc[] include::GetFeedsOfMember.adoc[] include::UpdateAFeed.adoc[] include::DeleteAFeed.adoc[] +include::GetRecommendFeeds.adoc[] +include::UpdateViewedFeeds.adoc[] include::CheckLiked.adoc[] include::LikeAFeed.adoc[] include::CancelLike.adoc[] diff --git a/src/main/java/com/stoury/controller/FeedController.java b/src/main/java/com/stoury/controller/FeedController.java index c6e9ef6..8aa4531 100644 --- a/src/main/java/com/stoury/controller/FeedController.java +++ b/src/main/java/com/stoury/controller/FeedController.java @@ -62,4 +62,14 @@ public void deleteFeed(@AuthenticationPrincipal AuthenticatedMember authenticate @PathVariable Long feedId) { feedService.deleteFeedIfOwner(feedId, authenticatedMember.getId()); } + + @GetMapping("/feeds/recommend") + public List getRecommendedFeeds(@AuthenticationPrincipal AuthenticatedMember authenticatedMember) { + return feedService.getRecommendedFeeds(authenticatedMember.getId()); + } + + @PostMapping("/feeds/viewed/{feedId}") + public void addViewedFeeds(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, @PathVariable Long feedId) { + feedService.clickLogUpdate(authenticatedMember.getId(), feedId); + } } diff --git a/src/main/java/com/stoury/domain/ClickLog.java b/src/main/java/com/stoury/domain/ClickLog.java new file mode 100644 index 0000000..b4cdd25 --- /dev/null +++ b/src/main/java/com/stoury/domain/ClickLog.java @@ -0,0 +1,31 @@ +package com.stoury.domain; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "CLICK_LOG") +public class ClickLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "MEMBER_ID") + private Long memberId; + @Column(name = "FEED_ID") + private Long feedId; + @Column(name = "CREATED_AT") + private LocalDateTime createdAt; + + @Builder + public ClickLog(Long memberId, Long feedId, LocalDateTime createdAt) { + this.memberId = memberId; + this.feedId = feedId; + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/stoury/repository/ClickLogRepository.java b/src/main/java/com/stoury/repository/ClickLogRepository.java new file mode 100644 index 0000000..ebc7475 --- /dev/null +++ b/src/main/java/com/stoury/repository/ClickLogRepository.java @@ -0,0 +1,21 @@ +package com.stoury.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.stoury.domain.ClickLog; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class ClickLogRepository { + private final EntityManager entityManager; + private final JPAQueryFactory jpaQueryFactory; + + @Transactional + public ClickLog save(ClickLog feedClick) { + entityManager.persist(feedClick); + return feedClick; + } +} diff --git a/src/main/java/com/stoury/repository/FeedRepository.java b/src/main/java/com/stoury/repository/FeedRepository.java index 455a524..8572594 100644 --- a/src/main/java/com/stoury/repository/FeedRepository.java +++ b/src/main/java/com/stoury/repository/FeedRepository.java @@ -1,21 +1,27 @@ package com.stoury.repository; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import com.stoury.domain.Feed; import com.stoury.domain.Member; +import com.stoury.domain.Tag; import com.stoury.projection.FeedResponseEntity; +import com.stoury.utils.cachekeys.PageSize; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Optional; +import static com.stoury.domain.QClickLog.clickLog; import static com.stoury.domain.QFeed.feed; import static com.stoury.domain.QTag.tag; import static com.stoury.projection.QFeedResponseEntity.feedResponseEntity; @@ -83,6 +89,14 @@ public List findByTagNameAndIdLessThan(String tagName, Long .fetch(); } + @Transactional(readOnly = true) + public List findAllFeedsByIdIn(Collection feedIds) { + return jpaQueryFactory + .selectFrom(feedResponseEntity) + .where(feedResponseEntity.feedId.in(feedIds)) + .fetch(); + } + private List findAllFeedIdByTagAndIdLessThan(String tagName, Long offsetId, Pageable page) { return jpaQueryFactory .select(feed.id) @@ -148,10 +162,27 @@ public List saveAll(Collection feeds) { return feeds.stream().map(this::save).toList(); } + @Transactional + public List saveAllFeedResponses(Collection feeds) { + return feeds.stream().map(this::saveFeedResponse).toList(); + } + @Transactional public void deleteFeedResponseById(Long feedId) { jpaQueryFactory.delete(feedResponseEntity) .where(feedResponseEntity.feedId.eq(feedId)) .execute(); } + + @Transactional(readOnly = true) + public List findRandomFeedIdsByTagName(Collection tagNames){ + return jpaQueryFactory. + select(feed.id) + .from(feed) + .join(feed.tags, tag) + .where(tag.in(tagNames)) + .orderBy(Expressions.numberTemplate(Double.class, "function('rand')").asc()) + .limit(PageSize.RANDOM_FEEDS_FETCH_SIZE) + .fetch(); + } } diff --git a/src/main/java/com/stoury/repository/TagRepository.java b/src/main/java/com/stoury/repository/TagRepository.java index b2a20c6..dec9449 100644 --- a/src/main/java/com/stoury/repository/TagRepository.java +++ b/src/main/java/com/stoury/repository/TagRepository.java @@ -1,14 +1,22 @@ package com.stoury.repository; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.stoury.domain.QClickLog; +import com.stoury.domain.QFeed; import com.stoury.domain.Tag; +import com.stoury.utils.cachekeys.PageSize; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; import java.util.Optional; +import static com.stoury.domain.QClickLog.*; +import static com.stoury.domain.QFeed.*; import static com.stoury.domain.QTag.tag; @Repository @@ -23,6 +31,12 @@ public Tag save(Tag saveTag) { return saveTag; } + @Transactional + public List saveAll(Collection saveTags) { + saveTags.forEach(entityManager::persist); + return saveTags.stream().toList(); + } + @Transactional public Tag saveAndFlush(Tag saveTag) { entityManager.persist(saveTag); @@ -48,4 +62,18 @@ public Optional findByTagName(String tagName) { .fetchFirst() ); } + + public List findAllByMemberIdAndFrequency(Long memberId) { + return jpaQueryFactory + .select(tag) + .from(clickLog) + .join(feed).on(clickLog.feedId.eq(feed.id)) + .join(feed.tags, tag) + .where(clickLog.memberId.eq(memberId) + .and(clickLog.createdAt.between(LocalDateTime.now().minusDays(7), LocalDateTime.now()))) + .groupBy(tag) + .orderBy(tag.count().desc()) + .limit(PageSize.FREQUENT_TAGS_SIZE) + .fetch(); + } } diff --git a/src/main/java/com/stoury/service/FeedService.java b/src/main/java/com/stoury/service/FeedService.java index 7bbcd43..c14ac26 100644 --- a/src/main/java/com/stoury/service/FeedService.java +++ b/src/main/java/com/stoury/service/FeedService.java @@ -1,10 +1,7 @@ package com.stoury.service; import com.fasterxml.jackson.core.type.TypeReference; -import com.stoury.domain.Feed; -import com.stoury.domain.GraphicContent; -import com.stoury.domain.Member; -import com.stoury.domain.Tag; +import com.stoury.domain.*; import com.stoury.dto.SimpleMemberResponse; import com.stoury.dto.feed.*; import com.stoury.event.*; @@ -13,9 +10,7 @@ import com.stoury.exception.feed.FeedSearchException; import com.stoury.exception.member.MemberSearchException; import com.stoury.projection.FeedResponseEntity; -import com.stoury.repository.FeedRepository; -import com.stoury.repository.LikeRepository; -import com.stoury.repository.MemberRepository; +import com.stoury.repository.*; import com.stoury.service.location.LocationService; import com.stoury.service.storage.StorageService; import com.stoury.utils.FileUtils; @@ -34,10 +29,8 @@ import org.springframework.web.multipart.MultipartFile; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Set; +import java.time.LocalDateTime; +import java.util.*; import java.util.stream.Collectors; @Service @@ -53,6 +46,7 @@ public class FeedService { private final LocationService locationService; private final ApplicationEventPublisher eventPublisher; private final JsonMapper jsonMapper; + private final ClickLogRepository clickLogRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) public FeedResponse createFeed(Long writerId, FeedCreateRequest feedCreateRequest, @@ -225,4 +219,38 @@ public FeedResponse getFeed(Long feedId) { return toFeedResponse(feedResponseEntity); } + + @Transactional(readOnly = true) + public List getRecommendedFeeds(Long memberId) { + Long memberIdNonNull = Objects.requireNonNull(memberId); + if (memberRepository.findById(memberIdNonNull).isEmpty()) { + throw new MemberSearchException(); + } + + List frequentTags = tagService.getFrequentTags(memberId); + List recommendFeedIds = feedRepository.findRandomFeedIdsByTagName(frequentTags); + List recommendFeeds = feedRepository.findAllFeedsByIdIn(recommendFeedIds); + + return recommendFeeds.stream().map(this::toFeedResponse).toList(); + } + + @Transactional(readOnly = true) + public void clickLogUpdate(Long memberId, Long feedId) { + Long memberIdNonNull = Objects.requireNonNull(memberId); + Long feedIdNonNull = Objects.requireNonNull(feedId); + if (memberRepository.findById(memberIdNonNull).isEmpty()) { + throw new MemberSearchException(); + } + if (feedRepository.findById(feedIdNonNull).isEmpty()) { + throw new FeedSearchException(); + } + + ClickLog clickLog = ClickLog.builder() + .memberId(memberIdNonNull) + .feedId(feedIdNonNull) + .createdAt(LocalDateTime.now()) + .build(); + + clickLogRepository.save(clickLog); + } } diff --git a/src/main/java/com/stoury/service/TagService.java b/src/main/java/com/stoury/service/TagService.java index 18913b1..5541e07 100644 --- a/src/main/java/com/stoury/service/TagService.java +++ b/src/main/java/com/stoury/service/TagService.java @@ -8,6 +8,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor public class TagService { @@ -30,4 +32,8 @@ private Tag createTagEntity(String tagName) { return new Tag(tagName); } + + public List getFrequentTags(Long memberId) { + return tagRepository.findAllByMemberIdAndFrequency(memberId); + } } diff --git a/src/main/java/com/stoury/utils/cachekeys/PageSize.java b/src/main/java/com/stoury/utils/cachekeys/PageSize.java index 76ddbcc..b580cf9 100644 --- a/src/main/java/com/stoury/utils/cachekeys/PageSize.java +++ b/src/main/java/com/stoury/utils/cachekeys/PageSize.java @@ -6,4 +6,7 @@ public class PageSize { public static final int DIARY_PAGE_SIZE = 10; public static final int COMMENT_PAGE_SIZE = 20; public static final int CHAT_PAGE_SIZE = 50; + public static final int RECOMMEND_FEEDS_SIZE = 5; + public static final int FREQUENT_TAGS_SIZE = 20; + public static final int RANDOM_FEEDS_FETCH_SIZE = 20; } diff --git a/src/main/java/com/stoury/utils/cachekeys/RecommendFeedsKey.java b/src/main/java/com/stoury/utils/cachekeys/RecommendFeedsKey.java new file mode 100644 index 0000000..5c9107e --- /dev/null +++ b/src/main/java/com/stoury/utils/cachekeys/RecommendFeedsKey.java @@ -0,0 +1,8 @@ +package com.stoury.utils.cachekeys; + +public class RecommendFeedsKey { + public static final String RECOMMEND_FEEDS_KEY = "RecommendFeeds:"; + public static String getRecommendFeedsKey(String memberId){ + return RECOMMEND_FEEDS_KEY + memberId; + } +} diff --git a/src/main/java/com/stoury/utils/cachekeys/ViewedFeedsKey.java b/src/main/java/com/stoury/utils/cachekeys/ViewedFeedsKey.java new file mode 100644 index 0000000..201ec65 --- /dev/null +++ b/src/main/java/com/stoury/utils/cachekeys/ViewedFeedsKey.java @@ -0,0 +1,8 @@ +package com.stoury.utils.cachekeys; + +public class ViewedFeedsKey { + public static final String VIEWED_FEEDS_KEY = "ViewedFeed:"; + public static String getViewedFeedsKey(String memberId){ + return VIEWED_FEEDS_KEY + memberId; + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index c572ac0..3d97fdb 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -12,6 +12,8 @@ spring: url: jdbc:mariadb://${db.host}:3306/stouryprod?allowPublicKeyRetrieval=true&useSSL=false&sendFractionalSeconds=false username: ${db.username} password: ${db.password} + flyway: + out-of-order: false logging: level: org.hibernate.orm.jdbc.bind: off @@ -21,5 +23,6 @@ google: geocoding: api-key: ${geocode.apikey:NONE} origin: ${origin:NONE} +path-prefix: /feeds token: secret: ${token.secret:NONE} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 99c95cf..ccdbdf1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,7 +32,7 @@ spring: batch: job: enabled: false - names: jobPopularSpots,jobYearlyDiaries,jobDailyFeeds,jobMonthlyFeeds,jobWeeklyFeeds + names: jobPopularSpots,jobYearlyDiaries,jobDailyFeeds,jobMonthlyFeeds,jobWeeklyFeeds,jobRecommendFeeds jdbc: initialize-schema: always datasource: @@ -63,6 +63,7 @@ spring: enabled: true baseline-on-migrate: true baseline-version: 0 + out-of-order: true servlet: multipart: max-request-size: 50MB @@ -72,7 +73,7 @@ logging: org.hibernate.orm.jdbc.bind: trace com.zaxxer.hikari.HikariConfig: DEBUG com.zaxxer.hikari: TRACE -path-prefix: /feeds +path-prefix: $HOME/trash/feeds profileImage: path-prefix: /members/profiles google: diff --git a/src/main/resources/db/migration/V027_1__create_table_click_log_and_interested_tags.sql b/src/main/resources/db/migration/V027_1__create_table_click_log_and_interested_tags.sql new file mode 100644 index 0000000..acb42df --- /dev/null +++ b/src/main/resources/db/migration/V027_1__create_table_click_log_and_interested_tags.sql @@ -0,0 +1,10 @@ +CREATE TABLE `CLICK_LOG` +( + `ID` BIGINT NOT NULL AUTO_INCREMENT, + `MEMBER_ID` BIGINT NOT NULL , + `FEED_ID` BIGINT NOT NULL , + `CREATED_AT` DATETIME(6) NOT NULL , + PRIMARY KEY (`ID`), + INDEX `IDX_MEMBER_CREATED` (`MEMBER_ID`, `CREATED_AT`) +) ENGINE = InnoDB + DEFAULT CHARSET = UTF8MB4; \ No newline at end of file diff --git a/src/main/resources/static/docs/CreateADiary.html b/src/main/resources/static/docs/CreateADiary.html index ea0ce0f..298c0ad 100644 --- a/src/main/resources/static/docs/CreateADiary.html +++ b/src/main/resources/static/docs/CreateADiary.html @@ -493,7 +493,7 @@

HTTP response

"country" : "United States" }, "likes" : 20, - "createdAt" : "2024-04-07T21:40:26.546276" + "createdAt" : "2024-04-20T21:04:02.652722" } ], "2" : [ { "feedId" : 2, @@ -517,7 +517,7 @@

HTTP response

"country" : "United States" }, "likes" : 13, - "createdAt" : "2024-04-08T21:40:26.552121" + "createdAt" : "2024-04-21T21:04:02.658349" }, { "feedId" : 3, "writer" : { @@ -540,11 +540,11 @@

HTTP response

"country" : "United States" }, "likes" : 52, - "createdAt" : "2024-04-09T21:40:26.553636" + "createdAt" : "2024-04-22T21:04:02.659411" } ] }, - "startDate" : "2024-04-07", - "endDate" : "2024-04-09", + "startDate" : "2024-04-20", + "endDate" : "2024-04-22", "city" : "Colorado", "country" : "United States", "likes" : 85, diff --git a/src/main/resources/static/docs/GetChildComments.html b/src/main/resources/static/docs/GetChildComments.html index 04f09c0..6a47e2d 100644 --- a/src/main/resources/static/docs/GetChildComments.html +++ b/src/main/resources/static/docs/GetChildComments.html @@ -468,7 +468,7 @@

HTTP response

}, "parentCommentId" : 1, "textContent" : "First child comment", - "createdAt" : "2024-04-07T21:40:26.473684" + "createdAt" : "2024-04-20T21:04:02.587327" }, { "id" : 12, "writerResponse" : { @@ -477,7 +477,7 @@

HTTP response

}, "parentCommentId" : 1, "textContent" : "Second child comment", - "createdAt" : "2024-04-07T21:40:26.474563" + "createdAt" : "2024-04-20T21:04:02.588017" }, { "id" : 13, "writerResponse" : { @@ -486,7 +486,7 @@

HTTP response

}, "parentCommentId" : 1, "textContent" : "This comment was deleted", - "createdAt" : "2024-04-07T21:40:26.474959" + "createdAt" : "2024-04-20T21:04:02.588349" } ] diff --git a/src/main/resources/static/docs/GetComments.html b/src/main/resources/static/docs/GetComments.html index 0975911..f0d17f0 100644 --- a/src/main/resources/static/docs/GetComments.html +++ b/src/main/resources/static/docs/GetComments.html @@ -469,7 +469,7 @@

HTTP response

"feedId" : 1, "hasNestedComments" : false, "textContent" : "First comment", - "createdAt" : "2024-04-07T21:40:26.449217" + "createdAt" : "2024-04-20T21:04:02.566029" }, { "id" : 2, "writer" : { @@ -479,7 +479,7 @@

HTTP response

"feedId" : 1, "hasNestedComments" : true, "textContent" : "This comment was deleted", - "createdAt" : "2024-04-07T21:40:26.451538" + "createdAt" : "2024-04-20T21:04:02.568001" }, { "id" : 3, "writer" : { @@ -489,7 +489,7 @@

HTTP response

"feedId" : 1, "hasNestedComments" : true, "textContent" : "Third comment", - "createdAt" : "2024-04-07T21:40:26.452142" + "createdAt" : "2024-04-20T21:04:02.568308" } ] diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 8c1098d..ad2581c 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -494,6 +494,19 @@

Stoury API docs

  • Path parameters
  • +
  • Get recommend feeds + +
  • +
  • Update viewed feeds + +
  • Check liked or not
    • HTTP request
    • @@ -1160,6 +1173,146 @@

      Path parameters

      +

      Get recommend feeds

      +
      +
      +

      HTTP request

      +
      +
      +
      GET /feeds/recommend HTTP/1.1
      +Host: localhost:8080
      +
      +
      +
      +
      +

      HTTP response

      +
      +
      +
      HTTP/1.1 200 OK
      +Content-Type: application/json;charset=UTF-8
      +Content-Length: 1388
      +
      +[ {
      +  "feedId" : 10,
      +  "writer" : {
      +    "id" : 2,
      +    "username" : "tester2"
      +  },
      +  "graphicContentsPaths" : [ {
      +    "id" : 20,
      +    "path" : "/file1.jpeg"
      +  }, {
      +    "id" : 21,
      +    "path" : "/file2.jpeg"
      +  } ],
      +  "textContent" : "recommend feed1",
      +  "latitude" : 36.5,
      +  "longitude" : 127.5,
      +  "tagNames" : [ "tag1", "tag2", "tag3" ],
      +  "location" : {
      +    "city" : "city1",
      +    "country" : "Country1"
      +  },
      +  "likes" : 3,
      +  "createdAt" : "2024-12-31T00:00:00"
      +}, {
      +  "feedId" : 233,
      +  "writer" : {
      +    "id" : 3,
      +    "username" : "tester3"
      +  },
      +  "graphicContentsPaths" : [ {
      +    "id" : 45,
      +    "path" : "/file3.jpeg"
      +  }, {
      +    "id" : 46,
      +    "path" : "/file4.jpeg"
      +  } ],
      +  "textContent" : "recommend feed2",
      +  "latitude" : 36.5,
      +  "longitude" : 127.5,
      +  "tagNames" : [ "tag5", "tag2", "tag3" ],
      +  "location" : {
      +    "city" : "city1",
      +    "country" : "Country1"
      +  },
      +  "likes" : 99,
      +  "createdAt" : "2024-12-31T00:00:00"
      +}, {
      +  "feedId" : 3456,
      +  "writer" : {
      +    "id" : 3,
      +    "username" : "tester3"
      +  },
      +  "graphicContentsPaths" : [ {
      +    "id" : 99,
      +    "path" : "/file6.jpeg"
      +  }, {
      +    "id" : 100,
      +    "path" : "/file7.jpeg"
      +  } ],
      +  "textContent" : "recommend feed3",
      +  "latitude" : 36.5,
      +  "longitude" : 127.5,
      +  "tagNames" : [ "tag1", "tag2", "tag99" ],
      +  "location" : {
      +    "city" : "city1",
      +    "country" : "Country1"
      +  },
      +  "likes" : 3,
      +  "createdAt" : "2024-12-31T00:00:00"
      +} ]
      +
      +
      +
      +
      +
      +
      +

      Update viewed feeds

      +
      +
      +

      HTTP request

      +
      +
      +
      POST /feeds/viewed/1 HTTP/1.1
      +Host: localhost:8080
      +Content-Type: application/x-www-form-urlencoded
      +
      +
      +
      +
      +

      HTTP response

      +
      +
      +
      HTTP/1.1 200 OK
      +
      +
      +
      +
      +

      Path parameters

      + + ++++ + + + + + + + + + + + + +
      Table 1. /feeds/viewed/{feedId}
      ParameterDescription

      feedId

      id of feed

      +
      +
      +
      +

      Check liked or not

      @@ -1448,7 +1601,7 @@

      HTTP response

      "feedId" : 1, "hasNestedComments" : false, "textContent" : "First comment", - "createdAt" : "2024-04-07T21:40:26.449217" + "createdAt" : "2024-04-20T21:04:02.566029" }, { "id" : 2, "writer" : { @@ -1458,7 +1611,7 @@

      HTTP response

      "feedId" : 1, "hasNestedComments" : true, "textContent" : "This comment was deleted", - "createdAt" : "2024-04-07T21:40:26.451538" + "createdAt" : "2024-04-20T21:04:02.568001" }, { "id" : 3, "writer" : { @@ -1468,7 +1621,7 @@

      HTTP response

      "feedId" : 1, "hasNestedComments" : true, "textContent" : "Third comment", - "createdAt" : "2024-04-07T21:40:26.452142" + "createdAt" : "2024-04-20T21:04:02.568308" } ]
      @@ -1546,7 +1699,7 @@

      HTTP response

      }, "parentCommentId" : 1, "textContent" : "First child comment", - "createdAt" : "2024-04-07T21:40:26.473684" + "createdAt" : "2024-04-20T21:04:02.587327" }, { "id" : 12, "writerResponse" : { @@ -1555,7 +1708,7 @@

      HTTP response

      }, "parentCommentId" : 1, "textContent" : "Second child comment", - "createdAt" : "2024-04-07T21:40:26.474563" + "createdAt" : "2024-04-20T21:04:02.588017" }, { "id" : 13, "writerResponse" : { @@ -1564,7 +1717,7 @@

      HTTP response

      }, "parentCommentId" : 1, "textContent" : "This comment was deleted", - "createdAt" : "2024-04-07T21:40:26.474959" + "createdAt" : "2024-04-20T21:04:02.588349" } ]
      @@ -1772,7 +1925,7 @@

      HTTP response

      "country" : "United States" }, "likes" : 20, - "createdAt" : "2024-04-07T21:40:26.546276" + "createdAt" : "2024-04-20T21:04:02.652722" } ], "2" : [ { "feedId" : 2, @@ -1796,7 +1949,7 @@

      HTTP response

      "country" : "United States" }, "likes" : 13, - "createdAt" : "2024-04-08T21:40:26.552121" + "createdAt" : "2024-04-21T21:04:02.658349" }, { "feedId" : 3, "writer" : { @@ -1819,11 +1972,11 @@

      HTTP response

      "country" : "United States" }, "likes" : 52, - "createdAt" : "2024-04-09T21:40:26.553636" + "createdAt" : "2024-04-22T21:04:02.659411" } ] }, - "startDate" : "2024-04-07", - "endDate" : "2024-04-09", + "startDate" : "2024-04-20", + "endDate" : "2024-04-22", "city" : "Colorado", "country" : "United States", "likes" : 85, @@ -2567,7 +2720,7 @@

      Query parameters

      diff --git a/src/test/groovy/com/stoury/IntegrationTest.groovy b/src/test/groovy/com/stoury/IntegrationTest.groovy index 705fa33..b226f9c 100644 --- a/src/test/groovy/com/stoury/IntegrationTest.groovy +++ b/src/test/groovy/com/stoury/IntegrationTest.groovy @@ -10,6 +10,8 @@ import com.stoury.repository.* import com.stoury.service.FeedService import com.stoury.service.MemberService import com.stoury.utils.cachekeys.PageSize +import com.stoury.utils.cachekeys.RecommendFeedsKey +import com.stoury.utils.cachekeys.ViewedFeedsKey import jakarta.persistence.EntityManager import jakarta.persistence.PersistenceContext import org.springframework.beans.factory.annotation.Autowired @@ -61,7 +63,6 @@ class IntegrationTest extends Specification { AuthenticationSuccessHandler authenticationSuccessHandler @Autowired LogoutSuccessHandler logoutSuccessHandler - def member = new Member("aaa@dddd.com", "qwdqwdqwd", "username", null) def setup() { diff --git a/src/test/groovy/com/stoury/controller/FeedControllerTest.groovy b/src/test/groovy/com/stoury/controller/FeedControllerTest.groovy index 5e961d4..e76f371 100644 --- a/src/test/groovy/com/stoury/controller/FeedControllerTest.groovy +++ b/src/test/groovy/com/stoury/controller/FeedControllerTest.groovy @@ -1,5 +1,6 @@ package com.stoury.controller +import com.stoury.domain.GraphicContent import com.stoury.dto.SimpleMemberResponse import com.stoury.dto.feed.FeedCreateRequest import com.stoury.dto.feed.FeedResponse @@ -246,4 +247,64 @@ class FeedControllerTest extends AbstractRestDocsTests { then: response.andExpect(status().isOk()) } + + def "Get recommend feeds"() { + given: + def writer = new AuthenticatedMember(1L, "test@email.com", "pwdpwd1111") + when(feedService.getRecommendedFeeds(any())).thenReturn( + [ + new FeedResponse(10L, new SimpleMemberResponse(2L, "tester2"), + [ + new GraphicContentResponse(20L, "/file1.jpeg"), + new GraphicContentResponse(21L, "/file2.jpeg"), + ], + "recommend feed1", + 36.5, + 127.5, + ["tag1", "tag2", "tag3"] as Set, + new LocationResponse("city1", "Country1"), + 3, LocalDateTime.of(2024, 12, 31, 0, 0)), + new FeedResponse(233L, new SimpleMemberResponse(3L, "tester3"), + [ + new GraphicContentResponse(45L, "/file3.jpeg"), + new GraphicContentResponse(46L, "/file4.jpeg"), + ], + "recommend feed2", + 36.5, + 127.5, + ["tag5", "tag2", "tag3"] as Set, + new LocationResponse("city1", "Country1"), + 99, LocalDateTime.of(2024, 12, 31, 0, 0)), + new FeedResponse(3456L, new SimpleMemberResponse(3L, "tester3"), + [ + new GraphicContentResponse(99L, "/file6.jpeg"), + new GraphicContentResponse(100L, "/file7.jpeg"), + ], + "recommend feed3", + 36.5, + 127.5, + ["tag1", "tag2", "tag99"] as Set, + new LocationResponse("city1", "Country1"), + 3, LocalDateTime.of(2024, 12, 31, 0, 0)), + ] + ) + when: + def response = mockMvc.perform(get("/feeds/recommend") + .with(authenticatedMember(writer))) + .andDo(document()) + then: + response.andExpect(status().isOk()) + } + + def "Update viewed feeds"() { + given: + def writer = new AuthenticatedMember(1L, "test@email.com", "pwdpwd1111") + def parameterDescriptor = parameterWithName("feedId").description("id of feed") + when: + def response = mockMvc.perform(post("/feeds/viewed/{feedId}", "1") + .with(authenticatedMember(writer))) + .andDo(documentWithPath(parameterDescriptor)) + then: + response.andExpect(status().isOk()) + } } diff --git a/src/test/groovy/com/stoury/service/FeedServiceTest.groovy b/src/test/groovy/com/stoury/service/FeedServiceTest.groovy index 70d45fc..b0a8456 100644 --- a/src/test/groovy/com/stoury/service/FeedServiceTest.groovy +++ b/src/test/groovy/com/stoury/service/FeedServiceTest.groovy @@ -1,6 +1,7 @@ package com.stoury.service import com.fasterxml.jackson.databind.ObjectMapper +import com.stoury.domain.ClickLog import com.stoury.domain.Feed import com.stoury.domain.GraphicContent import com.stoury.domain.Member @@ -15,9 +16,11 @@ import com.stoury.event.GraphicDeleteEvent import com.stoury.exception.authentication.NotAuthorizedException import com.stoury.exception.feed.FeedCreateException import com.stoury.projection.FeedResponseEntity +import com.stoury.repository.ClickLogRepository import com.stoury.repository.FeedRepository import com.stoury.repository.LikeRepository import com.stoury.repository.MemberRepository + import com.stoury.service.location.LocationService import com.stoury.service.storage.StorageService import com.stoury.utils.JsonMapper @@ -34,8 +37,9 @@ class FeedServiceTest extends Specification { def eventPublisher = Mock(ApplicationEventPublisher) def locationService = Mock(LocationService) def jsonMapper = new JsonMapper(new ObjectMapper()) + def clickLogRepository = Mock(ClickLogRepository) def feedService = new FeedService(storageService, feedRepository, memberRepository, likeRepository, - tagService, locationService, eventPublisher, jsonMapper) + tagService, locationService, eventPublisher, jsonMapper, clickLogRepository) def writer = Mock(Member) def feedCreateRequest = FeedCreateRequest.builder() @@ -163,4 +167,24 @@ class FeedServiceTest extends Specification { then: 1 * feedRepository.findAllFeedsByMemberAndIdLessThan(_, _, _) >> feeds } + + def "개인 맞춤 피드 제공 성공"(){ + given: + def feedIds = [1L,2L,4L,5L] + when: + feedService.getRecommendedFeeds(1L) + then: + 1 * tagService.getFrequentTags(1L) >> [] + 1 * feedRepository.findRandomFeedIdsByTagName(_) >> feedIds + 1 * feedRepository.findAllFeedsByIdIn(feedIds) >> [] + } + + def "사용자가 피드를 조회시 기록을 남김"(){ + given: + feedRepository.findById(_ as Long) >> Optional.of(new Feed()) + when: + feedService.clickLogUpdate(1L,1L) + then: + 1 * clickLogRepository.save(_ as ClickLog) + } }