diff --git a/backend/src/main/java/com/openisle/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index 100c0a2e3..9fabbacbd 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -119,6 +119,7 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll() .requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll() + .requestMatchers(HttpMethod.GET, "/api/rss").permitAll() .requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN") @@ -154,7 +155,8 @@ public class SecurityConfig { uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") || uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") || uri.startsWith("/api/point-goods") || - uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals")); + uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") || + uri.startsWith("/api/rss")); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); diff --git a/backend/src/main/java/com/openisle/controller/AdminPostController.java b/backend/src/main/java/com/openisle/controller/AdminPostController.java index 8e17d1c3e..3c9414cad 100644 --- a/backend/src/main/java/com/openisle/controller/AdminPostController.java +++ b/backend/src/main/java/com/openisle/controller/AdminPostController.java @@ -45,4 +45,14 @@ public class AdminPostController { public PostSummaryDto unpin(@PathVariable Long id) { return postMapper.toSummaryDto(postService.unpinPost(id)); } + + @PostMapping("/{id}/rss-exclude") + public PostSummaryDto excludeFromRss(@PathVariable Long id) { + return postMapper.toSummaryDto(postService.excludeFromRss(id)); + } + + @PostMapping("/{id}/rss-include") + public PostSummaryDto includeInRss(@PathVariable Long id) { + return postMapper.toSummaryDto(postService.includeInRss(id)); + } } diff --git a/backend/src/main/java/com/openisle/controller/RssController.java b/backend/src/main/java/com/openisle/controller/RssController.java new file mode 100644 index 000000000..f0a3b98d2 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/RssController.java @@ -0,0 +1,78 @@ +package com.openisle.controller; + +import com.openisle.model.Post; +import com.openisle.service.PostService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@RestController +@RequiredArgsConstructor +public class RssController { + private final PostService postService; + + @Value("${app.website-url:https://www.open-isle.com}") + private String websiteUrl; + + private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)"); + private static final Pattern HTML_IMAGE = Pattern.compile("]+src=[\"']?([^\"'>]+)[\"']?[^>]*>"); + + @GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8") + public String feed() { + List posts = postService.listLatestRssPosts(10); + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append("OpenIsle RSS"); + sb.append("").append(websiteUrl).append(""); + sb.append("Latest posts"); + DateTimeFormatter fmt = DateTimeFormatter.RFC_1123_DATE_TIME; + for (Post p : posts) { + String link = websiteUrl + "/posts/" + p.getId(); + sb.append(""); + sb.append("<![CDATA[").append(p.getTitle()).append("]]>"); + sb.append("").append(link).append(""); + sb.append("").append(link).append(""); + sb.append("") + .append(p.getCreatedAt().atZone(ZoneId.systemDefault()).format(fmt)) + .append(""); + String desc = p.getContent() + "\n

更多细节请访问原文:" + link + "

"; + sb.append(""); + String img = firstImage(p.getContent()); + if (img != null) { + sb.append(""); + } + sb.append("
"); + } + sb.append("
"); + return sb.toString(); + } + + private String firstImage(String content) { + Matcher m = MD_IMAGE.matcher(content); + if (m.find()) { + return m.group(1); + } + m = HTML_IMAGE.matcher(content); + if (m.find()) { + return m.group(1); + } + return null; + } + + private String getMimeType(String url) { + String lower = url.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + return "image/jpeg"; + } +} diff --git a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java index d849c225b..e47a03e11 100644 --- a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java +++ b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java @@ -31,5 +31,6 @@ public class PostSummaryDto { private int pointReward; private PostType type; private LotteryDto lottery; + private boolean rssExcluded; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 58652f17b..a80da01f7 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -63,6 +63,7 @@ public class PostMapper { dto.setCommentCount(commentService.countComments(post.getId())); dto.setStatus(post.getStatus()); dto.setPinnedAt(post.getPinnedAt()); + dto.setRssExcluded(post.isRssExcluded()); List reactions = reactionService.getReactionsForPost(post.getId()) .stream() diff --git a/backend/src/main/java/com/openisle/model/Post.java b/backend/src/main/java/com/openisle/model/Post.java index 0f5a709f1..cbbc75a0e 100644 --- a/backend/src/main/java/com/openisle/model/Post.java +++ b/backend/src/main/java/com/openisle/model/Post.java @@ -67,4 +67,7 @@ public class Post { @Column private LocalDateTime pinnedAt; + @Column(nullable = false) + private boolean rssExcluded = false; + } diff --git a/backend/src/main/java/com/openisle/repository/PostRepository.java b/backend/src/main/java/com/openisle/repository/PostRepository.java index d764bb1dd..58083b193 100644 --- a/backend/src/main/java/com/openisle/repository/PostRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostRepository.java @@ -106,4 +106,6 @@ public interface PostRepository extends JpaRepository { "WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d") java.util.List countDailyRange(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + List findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus status, Pageable pageable); } diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 30a785ce5..2e1106e04 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -132,6 +132,23 @@ public class PostService { this.publishMode = publishMode; } + public List listLatestRssPosts(int limit) { + Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt")); + return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable); + } + + public Post excludeFromRss(Long id) { + Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + post.setRssExcluded(true); + return postRepository.save(post); + } + + public Post includeInRss(Long id) { + Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + post.setRssExcluded(false); + return postRepository.save(post); + } + public Post createPost(String username, Long categoryId, String title, diff --git a/backend/src/main/resources/db/migration/V2__add_rss_excluded_to_posts.sql b/backend/src/main/resources/db/migration/V2__add_rss_excluded_to_posts.sql new file mode 100644 index 000000000..e8183ccec --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_rss_excluded_to_posts.sql @@ -0,0 +1 @@ +ALTER TABLE posts ADD COLUMN rss_excluded BOOLEAN NOT NULL DEFAULT 0; diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index b87a251e5..55f69a1a0 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -268,6 +268,7 @@ const postReactions = ref([]) const comments = ref([]) const status = ref('PUBLISHED') const pinnedAt = ref(null) +const rssExcluded = ref(false) const isWaitingPostingComment = ref(false) const postTime = ref('') const postItems = ref([]) @@ -356,6 +357,11 @@ const articleMenuItems = computed(() => { } else { items.push({ text: '置顶', onClick: () => pinPost() }) } + if (rssExcluded.value) { + items.push({ text: '取消rss不推荐', onClick: () => includeRss() }) + } else { + items.push({ text: 'rss不推荐', onClick: () => excludeRss() }) + } } if (isAdmin.value && status.value === 'PENDING') { items.push({ text: '通过审核', onClick: () => approvePost() }) @@ -480,6 +486,7 @@ watchEffect(() => { subscribed.value = !!data.subscribed status.value = data.status pinnedAt.value = data.pinnedAt + rssExcluded.value = data.rssExcluded postTime.value = TimeManager.format(data.createdAt) lottery.value = data.lottery || null if (lottery.value && lottery.value.endTime) startCountdown() @@ -645,6 +652,36 @@ const unpinPost = async () => { } } +const excludeRss = async () => { + const token = getToken() + if (!token) return + const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-exclude`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + rssExcluded.value = true + toast.success('已标记为rss不推荐') + } else { + toast.error('操作失败') + } +} + +const includeRss = async () => { + const token = getToken() + if (!token) return + const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-include`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + rssExcluded.value = false + toast.success('已取消rss不推荐') + } else { + toast.error('操作失败') + } +} + const editPost = () => { navigateTo(`/posts/${postId}/edit`, { replace: true }) }