From 7e7cebbbe7ff95873cea5afba495fb28f272d895 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 16 Jan 2026 15:05:06 +0800 Subject: [PATCH] fix: add view history logic --- .../openisle/controller/UserController.java | 31 ++++ .../java/com/openisle/dto/PostReadDto.java | 12 ++ .../java/com/openisle/mapper/UserMapper.java | 8 + .../repository/PostReadRepository.java | 3 + .../com/openisle/service/PostReadService.java | 11 ++ .../db/migration/V9__add_post_reads.sql | 12 ++ frontend_nuxt/components/TimelineReadItem.vue | 149 ++++++++++++++++++ frontend_nuxt/pages/users/[id].vue | 80 +++++++++- 8 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/openisle/dto/PostReadDto.java create mode 100644 backend/src/main/resources/db/migration/V9__add_post_reads.sql create mode 100644 frontend_nuxt/components/TimelineReadItem.vue diff --git a/backend/src/main/java/com/openisle/controller/UserController.java b/backend/src/main/java/com/openisle/controller/UserController.java index cc4e239e8..fec6890a9 100644 --- a/backend/src/main/java/com/openisle/controller/UserController.java +++ b/backend/src/main/java/com/openisle/controller/UserController.java @@ -34,6 +34,7 @@ public class UserController { private final TagService tagService; private final SubscriptionService subscriptionService; private final LevelService levelService; + private final PostReadService postReadService; private final JwtService jwtService; private final UserMapper userMapper; private final TagMapper tagMapper; @@ -53,6 +54,9 @@ public class UserController { @Value("${app.user.tags-limit:50}") private int defaultTagsLimit; + @Value("${app.user.read-posts-limit:50}") + private int defaultReadPostsLimit; + @GetMapping("/me") @SecurityRequirement(name = "JWT") @Operation(summary = "Current user", description = "Get current authenticated user information") @@ -211,6 +215,33 @@ public class UserController { .collect(java.util.stream.Collectors.toList()); } + @GetMapping("/{identifier}/read-posts") + @SecurityRequirement(name = "JWT") + @Operation(summary = "User read posts", description = "Get post read history (self only)") + @ApiResponse( + responseCode = "200", + description = "Post read history", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostReadDto.class))) + ) + public ResponseEntity> userReadPosts( + @PathVariable("identifier") String identifier, + @RequestParam(value = "limit", required = false) Integer limit, + Authentication auth + ) { + User user = userService.findByIdentifier(identifier).orElseThrow(); + if (auth == null || !auth.getName().equals(user.getUsername())) { + return ResponseEntity.status(403).body(java.util.List.of()); + } + int l = limit != null ? limit : defaultReadPostsLimit; + return ResponseEntity.ok( + postReadService + .getRecentReadsByUser(user.getUsername(), l) + .stream() + .map(userMapper::toPostReadDto) + .collect(java.util.stream.Collectors.toList()) + ); + } + @GetMapping("/{identifier}/hot-posts") @Operation(summary = "User hot posts", description = "Get most reacted posts by user") @ApiResponse( diff --git a/backend/src/main/java/com/openisle/dto/PostReadDto.java b/backend/src/main/java/com/openisle/dto/PostReadDto.java new file mode 100644 index 000000000..b90de494a --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/PostReadDto.java @@ -0,0 +1,12 @@ +package com.openisle.dto; + +import java.time.LocalDateTime; +import lombok.Data; + +/** DTO for a user's post read record. */ +@Data +public class PostReadDto { + + private PostMetaDto post; + private LocalDateTime lastReadAt; +} diff --git a/backend/src/main/java/com/openisle/mapper/UserMapper.java b/backend/src/main/java/com/openisle/mapper/UserMapper.java index 8b17d97e0..c9796bcfb 100644 --- a/backend/src/main/java/com/openisle/mapper/UserMapper.java +++ b/backend/src/main/java/com/openisle/mapper/UserMapper.java @@ -3,6 +3,7 @@ package com.openisle.mapper; import com.openisle.dto.*; import com.openisle.model.Comment; import com.openisle.model.Post; +import com.openisle.model.PostRead; import com.openisle.model.User; import com.openisle.service.*; import java.util.stream.Collectors; @@ -115,4 +116,11 @@ public class UserMapper { } return dto; } + + public PostReadDto toPostReadDto(PostRead read) { + PostReadDto dto = new PostReadDto(); + dto.setPost(toMetaDto(read.getPost())); + dto.setLastReadAt(read.getLastReadAt()); + return dto; + } } diff --git a/backend/src/main/java/com/openisle/repository/PostReadRepository.java b/backend/src/main/java/com/openisle/repository/PostReadRepository.java index 389d0bd50..451e0b97d 100644 --- a/backend/src/main/java/com/openisle/repository/PostReadRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostReadRepository.java @@ -3,11 +3,14 @@ package com.openisle.repository; import com.openisle.model.Post; import com.openisle.model.PostRead; import com.openisle.model.User; +import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface PostReadRepository extends JpaRepository { Optional findByUserAndPost(User user, Post post); + List findByUserOrderByLastReadAtDesc(User user, Pageable pageable); long countByUser(User user); void deleteByPost(Post post); } diff --git a/backend/src/main/java/com/openisle/service/PostReadService.java b/backend/src/main/java/com/openisle/service/PostReadService.java index f827311f9..cf5686737 100644 --- a/backend/src/main/java/com/openisle/service/PostReadService.java +++ b/backend/src/main/java/com/openisle/service/PostReadService.java @@ -7,7 +7,10 @@ import com.openisle.repository.PostReadRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; import java.time.LocalDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service @@ -43,6 +46,14 @@ public class PostReadService { ); } + public List getRecentReadsByUser(String username, int limit) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Pageable pageable = PageRequest.of(0, limit); + return postReadRepository.findByUserOrderByLastReadAtDesc(user, pageable); + } + public long countReads(String username) { User user = userRepository .findByUsername(username) diff --git a/backend/src/main/resources/db/migration/V9__add_post_reads.sql b/backend/src/main/resources/db/migration/V9__add_post_reads.sql new file mode 100644 index 000000000..097386c38 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_post_reads.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS post_reads ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + post_id BIGINT NOT NULL, + last_read_at DATETIME(6) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY UK_post_reads_user_post (user_id, post_id), + KEY IDX_post_reads_user (user_id), + KEY IDX_post_reads_post (post_id), + CONSTRAINT FK_post_reads_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT FK_post_reads_post FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE +); diff --git a/frontend_nuxt/components/TimelineReadItem.vue b/frontend_nuxt/components/TimelineReadItem.vue new file mode 100644 index 000000000..70053f947 --- /dev/null +++ b/frontend_nuxt/components/TimelineReadItem.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue index d566046cd..a2d99b307 100644 --- a/frontend_nuxt/pages/users/[id].vue +++ b/frontend_nuxt/pages/users/[id].vue @@ -191,14 +191,25 @@ > 评论和回复 +
+ 浏览记录 +
- + + + +
@@ -276,6 +292,7 @@ import BaseTabs from '~/components/BaseTabs.vue' import LevelProgress from '~/components/LevelProgress.vue' import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue' import TimelinePostItem from '~/components/TimelinePostItem.vue' +import TimelineReadItem from '~/components/TimelineReadItem.vue' import TimelineTagItem from '~/components/TimelineTagItem.vue' import BaseUserAvatar from '~/components/BaseUserAvatar.vue' import UserList from '~/components/UserList.vue' @@ -299,12 +316,15 @@ const hotReplies = ref([]) const hotTags = ref([]) const favoritePosts = ref([]) const timelineItems = ref([]) +const readPosts = ref([]) const timelineFilter = ref('all') const filteredTimelineItems = computed(() => { if (timelineFilter.value === 'articles') { return timelineItems.value.filter((item) => item.type === 'post') } else if (timelineFilter.value === 'comments') { return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply') + } else if (timelineFilter.value === 'reads') { + return [] } return timelineItems.value }) @@ -477,6 +497,27 @@ const fetchTimeline = async () => { timelineItems.value = combineDiscussionItems(mapped) } +const fetchReadHistory = async () => { + if (!isMine.value) { + readPosts.value = [] + return + } + const token = getToken() + if (!token) { + readPosts.value = [] + return + } + const res = await fetch(`${API_BASE_URL}/api/users/${username}/read-posts?limit=50`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + const data = await res.json() + readPosts.value = data.map((r) => ({ ...r, icon: 'file-text' })) + } else { + readPosts.value = [] + } +} + const fetchFollowUsers = async () => { const [followerRes, followingRes] = await Promise.all([ fetch(`${API_BASE_URL}/api/users/${username}/followers`), @@ -508,6 +549,12 @@ const loadTimeline = async () => { tabLoading.value = false } +const loadReadHistory = async () => { + tabLoading.value = true + await fetchReadHistory() + tabLoading.value = false +} + const loadFollow = async () => { tabLoading.value = true await fetchFollowUsers() @@ -624,8 +671,14 @@ onMounted(init) watch(selectedTab, async (val) => { // navigateTo({ query: { ...route.query, tab: val } }, { replace: true }) - if (val === 'timeline' && timelineItems.value.length === 0) { - await loadTimeline() + if (val === 'timeline') { + if (timelineFilter.value === 'reads') { + if (readPosts.value.length === 0) { + await loadReadHistory() + } + } else if (timelineItems.value.length === 0) { + await loadTimeline() + } } else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) { await loadFollow() } else if (val === 'favorites' && favoritePosts.value.length === 0) { @@ -634,6 +687,23 @@ watch(selectedTab, async (val) => { await loadAchievements() } }) + +watch(timelineFilter, async (val) => { + if (selectedTab.value !== 'timeline') return + if (val === 'reads') { + if (readPosts.value.length === 0) { + await loadReadHistory() + } + } else if (timelineItems.value.length === 0) { + await loadTimeline() + } +}) + +watch(isMine, (val) => { + if (!val && timelineFilter.value === 'reads') { + timelineFilter.value = 'all' + } +})