mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-31 23:18:32 +08:00
feat: expose rss feed endpoint
This commit is contained in:
@@ -119,6 +119,7 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/rss").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
|
.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/reaction-types") || uri.startsWith("/api/config") ||
|
||||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
||||||
uri.startsWith("/api/point-goods") ||
|
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 ")) {
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
String token = authHeader.substring(7);
|
String token = authHeader.substring(7);
|
||||||
|
|||||||
@@ -45,4 +45,14 @@ public class AdminPostController {
|
|||||||
public PostSummaryDto unpin(@PathVariable Long id) {
|
public PostSummaryDto unpin(@PathVariable Long id) {
|
||||||
return postMapper.toSummaryDto(postService.unpinPost(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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("<img[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
|
||||||
|
|
||||||
|
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
|
||||||
|
public String feed() {
|
||||||
|
List<Post> posts = postService.listLatestRssPosts(10);
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||||
|
sb.append("<rss version=\"2.0\">");
|
||||||
|
sb.append("<channel>");
|
||||||
|
sb.append("<title>OpenIsle RSS</title>");
|
||||||
|
sb.append("<link>").append(websiteUrl).append("</link>");
|
||||||
|
sb.append("<description>Latest posts</description>");
|
||||||
|
DateTimeFormatter fmt = DateTimeFormatter.RFC_1123_DATE_TIME;
|
||||||
|
for (Post p : posts) {
|
||||||
|
String link = websiteUrl + "/posts/" + p.getId();
|
||||||
|
sb.append("<item>");
|
||||||
|
sb.append("<title><![CDATA[").append(p.getTitle()).append("]]></title>");
|
||||||
|
sb.append("<link>").append(link).append("</link>");
|
||||||
|
sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
|
||||||
|
sb.append("<pubDate>")
|
||||||
|
.append(p.getCreatedAt().atZone(ZoneId.systemDefault()).format(fmt))
|
||||||
|
.append("</pubDate>");
|
||||||
|
String desc = p.getContent() + "\n<p>更多细节请访问原文:" + link + "</p>";
|
||||||
|
sb.append("<description><![CDATA[").append(desc).append("]]></description>");
|
||||||
|
String img = firstImage(p.getContent());
|
||||||
|
if (img != null) {
|
||||||
|
sb.append("<enclosure url=\"").append(img).append("\" type=\"")
|
||||||
|
.append(getMimeType(img)).append("\" />");
|
||||||
|
}
|
||||||
|
sb.append("</item>");
|
||||||
|
}
|
||||||
|
sb.append("</channel></rss>");
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,5 +31,6 @@ public class PostSummaryDto {
|
|||||||
private int pointReward;
|
private int pointReward;
|
||||||
private PostType type;
|
private PostType type;
|
||||||
private LotteryDto lottery;
|
private LotteryDto lottery;
|
||||||
|
private boolean rssExcluded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public class PostMapper {
|
|||||||
dto.setCommentCount(commentService.countComments(post.getId()));
|
dto.setCommentCount(commentService.countComments(post.getId()));
|
||||||
dto.setStatus(post.getStatus());
|
dto.setStatus(post.getStatus());
|
||||||
dto.setPinnedAt(post.getPinnedAt());
|
dto.setPinnedAt(post.getPinnedAt());
|
||||||
|
dto.setRssExcluded(post.isRssExcluded());
|
||||||
|
|
||||||
List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId())
|
List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId())
|
||||||
.stream()
|
.stream()
|
||||||
|
|||||||
@@ -67,4 +67,7 @@ public class Post {
|
|||||||
@Column
|
@Column
|
||||||
private LocalDateTime pinnedAt;
|
private LocalDateTime pinnedAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean rssExcluded = false;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,4 +106,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
"WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d")
|
"WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d")
|
||||||
java.util.List<Object[]> countDailyRange(@Param("start") LocalDateTime start,
|
java.util.List<Object[]> countDailyRange(@Param("start") LocalDateTime start,
|
||||||
@Param("end") LocalDateTime end);
|
@Param("end") LocalDateTime end);
|
||||||
|
|
||||||
|
List<Post> findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,23 @@ public class PostService {
|
|||||||
this.publishMode = publishMode;
|
this.publishMode = publishMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Post> 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,
|
public Post createPost(String username,
|
||||||
Long categoryId,
|
Long categoryId,
|
||||||
String title,
|
String title,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE posts ADD COLUMN rss_excluded BOOLEAN NOT NULL DEFAULT 0;
|
||||||
@@ -268,6 +268,7 @@ const postReactions = ref([])
|
|||||||
const comments = ref([])
|
const comments = ref([])
|
||||||
const status = ref('PUBLISHED')
|
const status = ref('PUBLISHED')
|
||||||
const pinnedAt = ref(null)
|
const pinnedAt = ref(null)
|
||||||
|
const rssExcluded = ref(false)
|
||||||
const isWaitingPostingComment = ref(false)
|
const isWaitingPostingComment = ref(false)
|
||||||
const postTime = ref('')
|
const postTime = ref('')
|
||||||
const postItems = ref([])
|
const postItems = ref([])
|
||||||
@@ -356,6 +357,11 @@ const articleMenuItems = computed(() => {
|
|||||||
} else {
|
} else {
|
||||||
items.push({ text: '置顶', onClick: () => pinPost() })
|
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') {
|
if (isAdmin.value && status.value === 'PENDING') {
|
||||||
items.push({ text: '通过审核', onClick: () => approvePost() })
|
items.push({ text: '通过审核', onClick: () => approvePost() })
|
||||||
@@ -480,6 +486,7 @@ watchEffect(() => {
|
|||||||
subscribed.value = !!data.subscribed
|
subscribed.value = !!data.subscribed
|
||||||
status.value = data.status
|
status.value = data.status
|
||||||
pinnedAt.value = data.pinnedAt
|
pinnedAt.value = data.pinnedAt
|
||||||
|
rssExcluded.value = data.rssExcluded
|
||||||
postTime.value = TimeManager.format(data.createdAt)
|
postTime.value = TimeManager.format(data.createdAt)
|
||||||
lottery.value = data.lottery || null
|
lottery.value = data.lottery || null
|
||||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
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 = () => {
|
const editPost = () => {
|
||||||
navigateTo(`/posts/${postId}/edit`, { replace: true })
|
navigateTo(`/posts/${postId}/edit`, { replace: true })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user