Skip to content

Commit fa87c05

Browse files
author
yqz
committed
增加文章导入导出功能
1 parent a01788a commit fa87c05

9 files changed

Lines changed: 713 additions & 1 deletion

File tree

OpenBlog-business/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@
9191
<version>8.5.17</version>
9292
</dependency>
9393

94+
<dependency>
95+
<groupId>org.yaml</groupId>
96+
<artifactId>snakeyaml</artifactId>
97+
</dependency>
98+
9499
<dependency>
95100
<groupId>org.projectlombok</groupId>
96101
<artifactId>lombok</artifactId>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.yqz.openblog.article.controller;
2+
3+
import com.yqz.openblog.article.dto.ArticleListItemResponse;
4+
import com.yqz.openblog.article.service.ArticleImportExportService;
5+
import com.yqz.openblog.article.service.ArticleImportExportService.ArticleMarkdownExportPayload;
6+
import com.yqz.openblog.common.ApiResponse;
7+
import com.yqz.openblog.common.web.ClientIpResolver;
8+
import com.yqz.openblog.security.CurrentUser;
9+
import jakarta.servlet.http.HttpServletRequest;
10+
import org.springframework.http.ContentDisposition;
11+
import org.springframework.http.HttpHeaders;
12+
import org.springframework.http.MediaType;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.access.prepost.PreAuthorize;
15+
import org.springframework.web.bind.annotation.*;
16+
import org.springframework.web.multipart.MultipartFile;
17+
18+
import java.nio.charset.StandardCharsets;
19+
20+
@RestController
21+
@RequestMapping("/api/v1")
22+
@CrossOrigin(origins = "*")
23+
public class ArticleImportExportController {
24+
25+
private final ArticleImportExportService importExportService;
26+
private final CurrentUser currentUser;
27+
28+
public ArticleImportExportController(ArticleImportExportService importExportService, CurrentUser currentUser) {
29+
this.importExportService = importExportService;
30+
this.currentUser = currentUser;
31+
}
32+
33+
@PostMapping(value = "/articles/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
34+
@PreAuthorize("hasAnyRole('ADMIN','AUTHOR')")
35+
public ApiResponse<ArticleListItemResponse> importMarkdown(
36+
@RequestParam("file") MultipartFile file,
37+
@RequestParam(value = "mode", defaultValue = "create") String mode,
38+
@RequestParam(value = "articleId", required = false) Long articleId) {
39+
Long uid = currentUser.userId();
40+
return ApiResponse.ok(importExportService.importMarkdown(uid, file, mode, articleId));
41+
}
42+
43+
@GetMapping(value = "/users/me/articles/{articleId}/export", produces = "text/markdown")
44+
@PreAuthorize("hasAnyRole('ADMIN','AUTHOR')")
45+
public ResponseEntity<byte[]> exportMarkdown(@PathVariable("articleId") Long articleId,
46+
HttpServletRequest request) {
47+
Long uid = currentUser.userId();
48+
String clientIp = ClientIpResolver.resolve(request);
49+
ArticleMarkdownExportPayload payload = importExportService.exportMarkdown(uid, articleId, clientIp);
50+
ContentDisposition disposition = ContentDisposition.attachment()
51+
.filename(payload.filename(), StandardCharsets.UTF_8)
52+
.build();
53+
return ResponseEntity.ok()
54+
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
55+
.contentType(new MediaType("text", "markdown", StandardCharsets.UTF_8))
56+
.body(payload.bytes());
57+
}
58+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.yqz.openblog.article.io;
2+
3+
/**
4+
* 从 .md 解析或导出前的文章 Markdown 文档模型。
5+
*/
6+
public class ArticleMarkdownDocument {
7+
8+
private String title;
9+
private String summary;
10+
private Long categoryId;
11+
private String coverMediaKey;
12+
private String contentMarkdown;
13+
14+
public String getTitle() {
15+
return title;
16+
}
17+
18+
public void setTitle(String title) {
19+
this.title = title;
20+
}
21+
22+
public String getSummary() {
23+
return summary;
24+
}
25+
26+
public void setSummary(String summary) {
27+
this.summary = summary;
28+
}
29+
30+
public Long getCategoryId() {
31+
return categoryId;
32+
}
33+
34+
public void setCategoryId(Long categoryId) {
35+
this.categoryId = categoryId;
36+
}
37+
38+
public String getCoverMediaKey() {
39+
return coverMediaKey;
40+
}
41+
42+
public void setCoverMediaKey(String coverMediaKey) {
43+
this.coverMediaKey = coverMediaKey;
44+
}
45+
46+
public String getContentMarkdown() {
47+
return contentMarkdown;
48+
}
49+
50+
public void setContentMarkdown(String contentMarkdown) {
51+
this.contentMarkdown = contentMarkdown;
52+
}
53+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.yqz.openblog.article.io;
2+
3+
import com.yqz.openblog.article.dto.ArticleDetailResponse;
4+
5+
/**
6+
* 将文章详情序列化为带 YAML Front Matter 的 Markdown 文件内容。
7+
*/
8+
public final class ArticleMarkdownExporter {
9+
10+
private ArticleMarkdownExporter() {
11+
}
12+
13+
public static byte[] toBytes(ArticleDetailResponse detail) {
14+
StringBuilder sb = new StringBuilder();
15+
sb.append("---\n");
16+
sb.append("title: ").append(yamlQuote(detail.getTitle())).append('\n');
17+
if (detail.getSummary() != null && !detail.getSummary().isBlank()) {
18+
sb.append("summary: ").append(yamlQuote(detail.getSummary())).append('\n');
19+
}
20+
if (detail.getCategoryId() != null) {
21+
sb.append("categoryId: ").append(detail.getCategoryId()).append('\n');
22+
}
23+
if (detail.getCoverMediaKey() != null && !detail.getCoverMediaKey().isBlank()) {
24+
sb.append("coverMediaKey: ").append(yamlQuote(detail.getCoverMediaKey())).append('\n');
25+
}
26+
if (detail.getId() != null) {
27+
sb.append("openblogId: ").append(detail.getId()).append('\n');
28+
}
29+
if (detail.getPublishedAt() != null) {
30+
sb.append("publishedAt: ").append(yamlQuote(detail.getPublishedAt().toString())).append('\n');
31+
}
32+
sb.append("---\n\n");
33+
String body = detail.getContentMarkdown() == null ? "" : detail.getContentMarkdown();
34+
sb.append(body);
35+
if (!body.endsWith("\n")) {
36+
sb.append('\n');
37+
}
38+
return sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
39+
}
40+
41+
public static String suggestedFilename(ArticleDetailResponse detail) {
42+
Long id = detail.getId();
43+
String title = detail.getTitle();
44+
String base;
45+
if (title == null || title.isBlank()) {
46+
base = "article-" + (id == null ? "export" : id);
47+
} else {
48+
base = title.trim();
49+
}
50+
base = base.replaceAll("[\\\\/:*?\"<>|]", "-").replaceAll("\\s+", " ").strip();
51+
if (base.isEmpty()) {
52+
base = "article-" + (id == null ? "export" : id);
53+
}
54+
if (base.length() > 80) {
55+
base = base.substring(0, 80).strip();
56+
}
57+
return base + ".md";
58+
}
59+
60+
private static String yamlQuote(String value) {
61+
if (value == null) {
62+
return "\"\"";
63+
}
64+
boolean needQuote = value.contains(":") || value.contains("#")
65+
|| value.startsWith(" ") || value.endsWith(" ")
66+
|| value.contains("\n") || value.contains("\"");
67+
if (!needQuote) {
68+
return value;
69+
}
70+
String escaped = value.replace("\\", "\\\\").replace("\"", "\\\"");
71+
return "\"" + escaped + "\"";
72+
}
73+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package com.yqz.openblog.article.io;
2+
3+
import com.yqz.openblog.common.BizException;
4+
import org.yaml.snakeyaml.LoaderOptions;
5+
import org.yaml.snakeyaml.Yaml;
6+
import org.yaml.snakeyaml.constructor.SafeConstructor;
7+
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.Map;
10+
import java.util.regex.Matcher;
11+
import java.util.regex.Pattern;
12+
13+
/**
14+
* 将 .md 文件文本解析为 {@link ArticleMarkdownDocument}。
15+
*/
16+
public final class ArticleMarkdownImporter {
17+
18+
private static final Pattern FIRST_H1 = Pattern.compile("(?m)^#\\s+(.+?)\\s*$");
19+
private static final int MAX_TITLE_LEN = 120;
20+
private static final int MAX_SUMMARY_LEN = 255;
21+
22+
private ArticleMarkdownImporter() {
23+
}
24+
25+
public static ArticleMarkdownDocument parse(byte[] bytes, String originalFilename) {
26+
if (bytes == null || bytes.length == 0) {
27+
throw new BizException(4002, "Markdown 文件为空");
28+
}
29+
String raw = stripBom(new String(bytes, StandardCharsets.UTF_8));
30+
String normalized = raw.replace("\r\n", "\n").replace('\r', '\n');
31+
32+
String frontMatter = null;
33+
String body;
34+
if (normalized.startsWith("---")) {
35+
int close = normalized.indexOf("\n---", 3);
36+
if (close > 0) {
37+
frontMatter = normalized.substring(3, close).trim();
38+
body = normalized.substring(close + 4);
39+
if (body.startsWith("\n")) {
40+
body = body.substring(1);
41+
}
42+
} else {
43+
body = normalized;
44+
}
45+
} else {
46+
body = normalized;
47+
}
48+
49+
ArticleMarkdownDocument doc = new ArticleMarkdownDocument();
50+
if (frontMatter != null && !frontMatter.isBlank()) {
51+
applyFrontMatter(doc, frontMatter);
52+
}
53+
54+
String content = body == null ? "" : body.strip();
55+
doc.setContentMarkdown(content);
56+
57+
if (doc.getTitle() == null || doc.getTitle().isBlank()) {
58+
doc.setTitle(inferTitle(content, originalFilename));
59+
}
60+
doc.setTitle(trimTo(doc.getTitle().trim(), MAX_TITLE_LEN));
61+
62+
if (doc.getSummary() != null) {
63+
doc.setSummary(trimTo(doc.getSummary().trim(), MAX_SUMMARY_LEN));
64+
}
65+
66+
if (doc.getTitle().isEmpty()) {
67+
throw new BizException(4002, "无法解析文章标题,请在 Front Matter 中设置 title 或使用 # 标题");
68+
}
69+
if (content.isEmpty()) {
70+
throw new BizException(4002, "Markdown 正文不能为空");
71+
}
72+
return doc;
73+
}
74+
75+
private static void applyFrontMatter(ArticleMarkdownDocument doc, String yamlBlock) {
76+
LoaderOptions options = new LoaderOptions();
77+
Yaml yaml = new Yaml(new SafeConstructor(options));
78+
Object loaded = yaml.load(yamlBlock);
79+
if (!(loaded instanceof Map<?, ?> map)) {
80+
return;
81+
}
82+
Object title = map.get("title");
83+
if (title != null) {
84+
doc.setTitle(String.valueOf(title).trim());
85+
}
86+
Object summary = map.get("summary");
87+
if (summary != null) {
88+
doc.setSummary(String.valueOf(summary).trim());
89+
}
90+
Object categoryId = map.get("categoryId");
91+
if (categoryId != null) {
92+
doc.setCategoryId(parseLong(categoryId));
93+
}
94+
Object cover = map.get("coverMediaKey");
95+
if (cover != null) {
96+
String key = String.valueOf(cover).trim();
97+
if (!key.isEmpty()) {
98+
doc.setCoverMediaKey(trimTo(key, 64));
99+
}
100+
}
101+
}
102+
103+
private static Long parseLong(Object value) {
104+
if (value instanceof Number n) {
105+
return n.longValue();
106+
}
107+
String s = String.valueOf(value).trim();
108+
if (s.isEmpty()) {
109+
return null;
110+
}
111+
try {
112+
return Long.parseLong(s);
113+
} catch (NumberFormatException ex) {
114+
throw new BizException(4002, "Front Matter 中 categoryId 无效");
115+
}
116+
}
117+
118+
private static String inferTitle(String body, String originalFilename) {
119+
Matcher m = FIRST_H1.matcher(body);
120+
if (m.find()) {
121+
return m.group(1).trim();
122+
}
123+
if (originalFilename != null && !originalFilename.isBlank()) {
124+
String name = originalFilename.trim();
125+
int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
126+
if (slash >= 0) {
127+
name = name.substring(slash + 1);
128+
}
129+
if (name.toLowerCase().endsWith(".md")) {
130+
name = name.substring(0, name.length() - 3);
131+
}
132+
if (!name.isBlank()) {
133+
return name.trim();
134+
}
135+
}
136+
return "";
137+
}
138+
139+
private static String stripBom(String s) {
140+
if (s != null && !s.isEmpty() && s.charAt(0) == '\uFEFF') {
141+
return s.substring(1);
142+
}
143+
return s;
144+
}
145+
146+
private static String trimTo(String s, int max) {
147+
if (s.length() <= max) {
148+
return s;
149+
}
150+
return s.substring(0, max);
151+
}
152+
}

0 commit comments

Comments
 (0)