Skip to content

Commit 81d9084

Browse files
author
yqz
committed
Merge branch 'feature/seo'
2 parents b8b7d4e + f2faf2b commit 81d9084

37 files changed

Lines changed: 1988 additions & 141 deletions

OpenBlog-business/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
<artifactId>spring-boot-starter-web</artifactId>
1919
</dependency>
2020

21+
<dependency>
22+
<groupId>org.springframework.boot</groupId>
23+
<artifactId>spring-boot-starter-thymeleaf</artifactId>
24+
</dependency>
25+
2126
<dependency>
2227
<groupId>org.springframework.boot</groupId>
2328
<artifactId>spring-boot-starter-validation</artifactId>

OpenBlog-business/src/main/java/com/yqz/openblog/OpenBlogApplication.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.yqz.openblog.config.AuthSecurityProperties;
44
import com.yqz.openblog.config.CorsProperties;
55
import com.yqz.openblog.config.SiteProperties;
6+
import com.yqz.openblog.seo.SeoProperties;
67
import org.springframework.boot.SpringApplication;
78
import org.springframework.boot.autoconfigure.SpringBootApplication;
89
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -13,7 +14,8 @@
1314
@EnableConfigurationProperties({
1415
SiteProperties.class,
1516
CorsProperties.class,
16-
AuthSecurityProperties.class
17+
AuthSecurityProperties.class,
18+
SeoProperties.class
1719
})
1820
public class OpenBlogApplication {
1921

OpenBlog-business/src/main/java/com/yqz/openblog/article/service/ArticleService.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.yqz.openblog.common.BizException;
1212
import com.yqz.openblog.common.PageResult;
1313
import com.yqz.openblog.category.service.CategoryService;
14+
import com.yqz.openblog.seo.service.BaiduPushService;
1415
import com.yqz.openblog.user.entity.User;
1516
import com.yqz.openblog.user.repo.UserMapper;
1617

@@ -37,6 +38,7 @@ public class ArticleService {
3738
private final ArticlePublishedContentCacheService publishedContentCache;
3839
private final CategoryService categoryService;
3940
private final MarkdownRenderer markdownRenderer;
41+
private final BaiduPushService baiduPushService;
4042

4143
public ArticleService(
4244
ArticleMapper articleMapper,
@@ -45,14 +47,16 @@ public ArticleService(
4547
ArticleViewService articleViewService,
4648
ArticlePublishedContentCacheService publishedContentCache,
4749
CategoryService categoryService,
48-
MarkdownRenderer markdownRenderer) {
50+
MarkdownRenderer markdownRenderer,
51+
BaiduPushService baiduPushService) {
4952
this.articleMapper = articleMapper;
5053
this.articleBodyMapper = articleBodyMapper;
5154
this.userMapper = userMapper;
5255
this.articleViewService = articleViewService;
5356
this.publishedContentCache = publishedContentCache;
5457
this.categoryService = categoryService;
5558
this.markdownRenderer = markdownRenderer;
59+
this.baiduPushService = baiduPushService;
5660
}
5761

5862
public ArticleListItemResponse mapListItem(Article a) {
@@ -326,6 +330,11 @@ public ArticleListItemResponse publish(Long authorId, Long articleId, Instant pu
326330
articleMapper.updateById(a);
327331
publishedContentCache.evict(articleId);
328332
publishedContentCache.evictPublishedList();
333+
334+
if (a.getStatus() == ArticleStatus.PUBLISHED) {
335+
baiduPushService.pushArticleUrl(articleId);
336+
}
337+
329338
return mapListItem(a);
330339
}
331340

OpenBlog-business/src/main/java/com/yqz/openblog/controller/SiteMetaController.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
import com.yqz.openblog.common.ApiResponse;
44
import com.yqz.openblog.config.SiteProperties;
55
import com.yqz.openblog.config.ClientIpResolver;
6+
import com.yqz.openblog.site.SiteConfigService;
67
import com.yqz.openblog.site.SiteStatsService;
78
import com.yqz.openblog.site.SiteVisitService;
89
import com.yqz.openblog.site.dto.SiteStatsResponse;
10+
import org.springframework.security.access.prepost.PreAuthorize;
911
import org.springframework.web.bind.annotation.CrossOrigin;
1012
import org.springframework.web.bind.annotation.GetMapping;
1113
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.PutMapping;
15+
import org.springframework.web.bind.annotation.RequestBody;
1216
import org.springframework.web.bind.annotation.RequestMapping;
1317
import org.springframework.web.bind.annotation.RestController;
1418

@@ -24,12 +28,14 @@ public class SiteMetaController {
2428
private final SiteProperties siteProperties;
2529
private final SiteStatsService siteStatsService;
2630
private final SiteVisitService siteVisitService;
31+
private final SiteConfigService siteConfigService;
2732

2833
public SiteMetaController(SiteProperties siteProperties, SiteStatsService siteStatsService,
29-
SiteVisitService siteVisitService) {
34+
SiteVisitService siteVisitService, SiteConfigService siteConfigService) {
3035
this.siteProperties = siteProperties;
3136
this.siteStatsService = siteStatsService;
3237
this.siteVisitService = siteVisitService;
38+
this.siteConfigService = siteConfigService;
3339
}
3440

3541
@GetMapping("/version")
@@ -51,4 +57,16 @@ public ApiResponse<Map<String, Object>> recordVisit(HttpServletRequest request)
5157
boolean recorded = siteVisitService.tryRecordVisit(clientIp);
5258
return ApiResponse.ok(Map.of("recorded", recorded));
5359
}
60+
61+
@GetMapping("/config")
62+
public ApiResponse<Map<String, String>> config() {
63+
return ApiResponse.ok(siteConfigService.getAllConfigs());
64+
}
65+
66+
@PutMapping("/config")
67+
@PreAuthorize("hasAnyRole('ADMIN','AUTHOR')")
68+
public ApiResponse<Map<String, Object>> updateConfig(@RequestBody Map<String, String> payload) {
69+
siteConfigService.updateConfigs(payload);
70+
return ApiResponse.ok(Map.of("updated", payload != null ? payload.size() : 0));
71+
}
5472
}

OpenBlog-business/src/main/java/com/yqz/openblog/security/SecurityConfig.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
5353
.authenticationEntryPoint(authenticationEntryPoint)
5454
.accessDeniedHandler(accessDeniedHandler))
5555
.authorizeHttpRequests(auth -> auth
56+
// SEO pages
57+
.requestMatchers(HttpMethod.GET, "/seo/**").permitAll()
58+
.requestMatchers(HttpMethod.GET, "/sitemap.xml").permitAll()
59+
.requestMatchers(HttpMethod.GET, "/robots.txt").permitAll()
5660
// Auth
5761
.requestMatchers("/api/v1/auth/**").permitAll()
5862
// 放行 CORS preflight
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.yqz.openblog.seo;
2+
3+
import lombok.Getter;
4+
import lombok.Setter;
5+
import org.springframework.boot.context.properties.ConfigurationProperties;
6+
7+
@Getter
8+
@Setter
9+
@ConfigurationProperties(prefix = "openblog.seo")
10+
public class SeoProperties {
11+
12+
private String siteUrl = "https://www.wecode.xin";
13+
private String siteName = "OpenBlog";
14+
private String siteDescription = "WeCode 技术博客";
15+
private String baiduPushToken;
16+
private boolean baiduPushEnabled = false;
17+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.yqz.openblog.seo.controller;
2+
3+
import com.yqz.openblog.seo.SeoProperties;
4+
import org.springframework.http.MediaType;
5+
import org.springframework.web.bind.annotation.GetMapping;
6+
import org.springframework.web.bind.annotation.RestController;
7+
8+
@RestController
9+
public class RobotsController {
10+
11+
private final SeoProperties seoProperties;
12+
13+
public RobotsController(SeoProperties seoProperties) {
14+
this.seoProperties = seoProperties;
15+
}
16+
17+
@GetMapping(value = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE)
18+
public String robots() {
19+
return "User-agent: *\n" +
20+
"Allow: /\n" +
21+
"Allow: /article/\n" +
22+
"Allow: /all\n" +
23+
"Allow: /changelog\n" +
24+
"Disallow: /console/\n" +
25+
"Disallow: /login\n" +
26+
"Disallow: /api/\n" +
27+
"\n" +
28+
"Sitemap: " + seoProperties.getSiteUrl() + "/sitemap.xml\n";
29+
}
30+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.yqz.openblog.seo.controller;
2+
3+
import com.yqz.openblog.article.dto.ArticleDetailResponse;
4+
import com.yqz.openblog.article.dto.ArticleListItemResponse;
5+
import com.yqz.openblog.article.service.ArticleService;
6+
import com.yqz.openblog.common.PageResult;
7+
import com.yqz.openblog.media.MediaProperties;
8+
import com.yqz.openblog.seo.SeoProperties;
9+
import org.springframework.stereotype.Controller;
10+
import org.springframework.ui.Model;
11+
import org.springframework.web.bind.annotation.GetMapping;
12+
import org.springframework.web.bind.annotation.PathVariable;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
15+
@Controller
16+
@RequestMapping("/seo")
17+
public class SeoPageController {
18+
19+
private final ArticleService articleService;
20+
private final SeoProperties seoProperties;
21+
private final MediaProperties mediaProperties;
22+
23+
public SeoPageController(ArticleService articleService,
24+
SeoProperties seoProperties,
25+
MediaProperties mediaProperties) {
26+
this.articleService = articleService;
27+
this.seoProperties = seoProperties;
28+
this.mediaProperties = mediaProperties;
29+
}
30+
31+
@GetMapping("/article/{id}")
32+
public String articleDetail(@PathVariable Long id, Model model) {
33+
ArticleDetailResponse article = articleService.detailPublished(id, "seo-crawler");
34+
model.addAttribute("article", article);
35+
model.addAttribute("siteUrl", seoProperties.getSiteUrl());
36+
model.addAttribute("siteName", seoProperties.getSiteName());
37+
model.addAttribute("coverUrl", buildCoverUrl(article.getCoverMediaKey()));
38+
return "seo/article";
39+
}
40+
41+
@GetMapping({"", "/"})
42+
public String home(Model model) {
43+
PageResult<ArticleListItemResponse> articles = articleService.listPublished(0, 20, null);
44+
model.addAttribute("articles", articles);
45+
model.addAttribute("siteUrl", seoProperties.getSiteUrl());
46+
model.addAttribute("siteName", seoProperties.getSiteName());
47+
model.addAttribute("siteDescription", seoProperties.getSiteDescription());
48+
return "seo/home";
49+
}
50+
51+
private String buildCoverUrl(String coverMediaKey) {
52+
if (coverMediaKey == null || coverMediaKey.isBlank()) {
53+
return null;
54+
}
55+
String base = mediaProperties.getPublicBaseUrl();
56+
if (base == null) base = "";
57+
return base + "/api/v1/media/files/" + coverMediaKey;
58+
}
59+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.yqz.openblog.seo.controller;
2+
3+
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4+
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
5+
import com.yqz.openblog.article.entity.Article;
6+
import com.yqz.openblog.article.entity.ArticleStatus;
7+
import com.yqz.openblog.article.repo.ArticleMapper;
8+
import com.yqz.openblog.seo.SeoProperties;
9+
import org.springframework.http.MediaType;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.RestController;
12+
13+
import java.time.ZoneOffset;
14+
import java.time.format.DateTimeFormatter;
15+
import java.util.List;
16+
17+
@RestController
18+
public class SitemapController {
19+
20+
private final ArticleMapper articleMapper;
21+
private final SeoProperties seoProperties;
22+
23+
public SitemapController(ArticleMapper articleMapper, SeoProperties seoProperties) {
24+
this.articleMapper = articleMapper;
25+
this.seoProperties = seoProperties;
26+
}
27+
28+
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
29+
public String sitemap() {
30+
LambdaQueryWrapper<Article> w = Wrappers.lambdaQuery();
31+
w.eq(Article::getStatus, ArticleStatus.PUBLISHED)
32+
.select(Article::getId, Article::getUpdatedAt, Article::getPublishedAt)
33+
.orderByDesc(Article::getPublishedAt);
34+
35+
List<Article> articles = articleMapper.selectList(w);
36+
String siteUrl = seoProperties.getSiteUrl();
37+
DateTimeFormatter fmt = DateTimeFormatter.ISO_DATE.withZone(ZoneOffset.UTC);
38+
39+
StringBuilder sb = new StringBuilder();
40+
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
41+
sb.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
42+
43+
sb.append(" <url>\n");
44+
sb.append(" <loc>").append(siteUrl).append("/</loc>\n");
45+
sb.append(" <changefreq>daily</changefreq>\n");
46+
sb.append(" <priority>1.0</priority>\n");
47+
sb.append(" </url>\n");
48+
49+
for (Article a : articles) {
50+
sb.append(" <url>\n");
51+
sb.append(" <loc>").append(siteUrl).append("/article/").append(a.getId()).append("</loc>\n");
52+
if (a.getUpdatedAt() != null) {
53+
sb.append(" <lastmod>").append(fmt.format(a.getUpdatedAt())).append("</lastmod>\n");
54+
} else if (a.getPublishedAt() != null) {
55+
sb.append(" <lastmod>").append(fmt.format(a.getPublishedAt())).append("</lastmod>\n");
56+
}
57+
sb.append(" <changefreq>weekly</changefreq>\n");
58+
sb.append(" <priority>0.8</priority>\n");
59+
sb.append(" </url>\n");
60+
}
61+
62+
sb.append("</urlset>");
63+
return sb.toString();
64+
}
65+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.yqz.openblog.seo.service;
2+
3+
import com.yqz.openblog.seo.SeoProperties;
4+
import org.slf4j.Logger;
5+
import org.slf4j.LoggerFactory;
6+
import org.springframework.scheduling.annotation.Async;
7+
import org.springframework.stereotype.Service;
8+
9+
import java.net.URI;
10+
import java.net.http.HttpClient;
11+
import java.net.http.HttpRequest;
12+
import java.net.http.HttpResponse;
13+
import java.time.Duration;
14+
import java.util.List;
15+
16+
@Service
17+
public class BaiduPushService {
18+
19+
private static final Logger log = LoggerFactory.getLogger(BaiduPushService.class);
20+
private static final String BAIDU_PUSH_API = "http://data.zz.baidu.com/urls";
21+
22+
private final SeoProperties seoProperties;
23+
private final HttpClient httpClient;
24+
25+
public BaiduPushService(SeoProperties seoProperties) {
26+
this.seoProperties = seoProperties;
27+
this.httpClient = HttpClient.newBuilder()
28+
.connectTimeout(Duration.ofSeconds(10))
29+
.build();
30+
}
31+
32+
@Async
33+
public void pushUrls(List<String> urls) {
34+
if (!seoProperties.isBaiduPushEnabled()) {
35+
return;
36+
}
37+
String token = seoProperties.getBaiduPushToken();
38+
if (token == null || token.isBlank()) {
39+
return;
40+
}
41+
if (urls == null || urls.isEmpty()) {
42+
return;
43+
}
44+
45+
String body = String.join("\n", urls);
46+
String apiUrl = BAIDU_PUSH_API + "?site=" + seoProperties.getSiteUrl() + "&token=" + token;
47+
48+
try {
49+
HttpRequest request = HttpRequest.newBuilder()
50+
.uri(URI.create(apiUrl))
51+
.header("Content-Type", "text/plain")
52+
.POST(HttpRequest.BodyPublishers.ofString(body))
53+
.timeout(Duration.ofSeconds(15))
54+
.build();
55+
56+
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
57+
log.info("Baidu push response [{}]: {}", response.statusCode(), response.body());
58+
} catch (Exception e) {
59+
log.warn("Baidu push failed: {}", e.getMessage());
60+
}
61+
}
62+
63+
public void pushArticleUrl(Long articleId) {
64+
String url = seoProperties.getSiteUrl() + "/article/" + articleId;
65+
pushUrls(List.of(url));
66+
}
67+
}

0 commit comments

Comments
 (0)