feat(system): 支持后台维护三语协议
This commit is contained in:
1 parent
16ba79a336
commit
22edf34ff1
12 files changed
+470
No files matched your search
+31
@@ -0,0 +1,31 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 服务条款与隐私协议。每种文档按语言各存一份。
|
||||
*/
|
||||
@Data
|
||||
@TableName("legal_document")
|
||||
public class LegalDocument {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** terms 或 privacy。 */
|
||||
private String docCode;
|
||||
|
||||
/** zh、en、id。 */
|
||||
private String lang;
|
||||
|
||||
private String title;
|
||||
private String content;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.system.dal.entity.LegalDocument;
|
||||
|
||||
/** 协议正文。 */
|
||||
public interface LegalDocumentMapper extends BaseMapper<LegalDocument> {
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.system.dal.entity.LegalDocument;
|
||||
import com.fruitshorts.system.dal.mapper.LegalDocumentMapper;
|
||||
import com.fruitshorts.system.vo.LegalDocumentVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 服务条款与隐私协议:后台保存三语文案,公开页按路径和语言输出 HTML。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LegalDocumentService {
|
||||
|
||||
private final LegalDocumentMapper legalDocumentMapper;
|
||||
|
||||
/** 六份协议。库里没有的语言用缺省文案补齐,方便后台第一次打开就能编辑。 */
|
||||
public List<LegalDocumentVO> list() {
|
||||
Map<String, LegalDocument> stored = index(legalDocumentMapper.selectList(null));
|
||||
List<LegalDocumentVO> result = new ArrayList<>();
|
||||
for (String code : LegalPages.DOC_CODES) {
|
||||
for (String lang : LegalPages.LANGS) {
|
||||
result.add(toView(code, lang, stored.get(LegalPages.key(code, lang))));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 按文档和语言覆盖保存。未知种类或语言直接拒绝。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void save(List<LegalDocumentVO> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "请提交协议内容");
|
||||
}
|
||||
for (LegalDocumentVO item : items) {
|
||||
upsert(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页面。路径只能是 privacy 或 terms,语言只能是 zh、en、id。
|
||||
* 不合法时返回 null,由控制器回 404。
|
||||
*/
|
||||
public String render(String path, String lang) {
|
||||
String code = LegalPages.docCodeOfPath(path);
|
||||
if (code == null || !LegalPages.LANGS.contains(lang)) {
|
||||
return null;
|
||||
}
|
||||
return LegalPageHtml.render(lang, contentOf(code, lang, find(code, lang)));
|
||||
}
|
||||
|
||||
private void upsert(LegalDocumentVO item) {
|
||||
String code = item == null ? null : item.getDocCode();
|
||||
String lang = item == null ? null : item.getLang();
|
||||
if (!LegalPages.DOC_CODES.contains(code) || !LegalPages.LANGS.contains(lang)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "协议种类或语言不正确");
|
||||
}
|
||||
String title = item.getTitle() == null ? "" : item.getTitle().trim();
|
||||
String content = item.getContent() == null ? "" : item.getContent();
|
||||
if (title.length() > LegalPages.TITLE_MAX || content.length() > LegalPages.CONTENT_MAX) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "协议标题或正文过长");
|
||||
}
|
||||
LegalDocument row = find(code, lang);
|
||||
if (row == null) {
|
||||
row = new LegalDocument();
|
||||
row.setDocCode(code);
|
||||
row.setLang(lang);
|
||||
}
|
||||
row.setTitle(title);
|
||||
row.setContent(content);
|
||||
row.setUpdatedAt(LocalDateTime.now());
|
||||
if (row.getId() == null) {
|
||||
legalDocumentMapper.insert(row);
|
||||
} else {
|
||||
legalDocumentMapper.updateById(row);
|
||||
}
|
||||
}
|
||||
|
||||
private LegalDocument find(String code, String lang) {
|
||||
return legalDocumentMapper.selectOne(Wrappers.<LegalDocument>lambdaQuery()
|
||||
.eq(LegalDocument::getDocCode, code)
|
||||
.eq(LegalDocument::getLang, lang)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
private Map<String, LegalDocument> index(List<LegalDocument> rows) {
|
||||
Map<String, LegalDocument> stored = new HashMap<>();
|
||||
if (rows == null) {
|
||||
return stored;
|
||||
}
|
||||
for (LegalDocument row : rows) {
|
||||
stored.put(LegalPages.key(row.getDocCode(), row.getLang()), row);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
private LegalDocumentVO toView(String code, String lang, LegalDocument row) {
|
||||
LegalDocumentVO view = new LegalDocumentVO();
|
||||
view.setDocCode(code);
|
||||
view.setLang(lang);
|
||||
view.setTitle("");
|
||||
view.setContent(row == null ? LegalPages.defaultContent(lang) : row.getContent());
|
||||
return view;
|
||||
}
|
||||
|
||||
private String contentOf(String code, String lang, LegalDocument row) {
|
||||
if (row != null && row.getContent() != null) {
|
||||
return row.getContent();
|
||||
}
|
||||
return LegalPages.defaultContent(lang);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.service;
|
||||
|
||||
import org.commonmark.parser.Parser;
|
||||
import org.commonmark.renderer.html.HtmlRenderer;
|
||||
|
||||
/**
|
||||
* 把协议 Markdown 包成手机上可读的 HTML。不另加标题,原文里的 HTML 会转义。
|
||||
*/
|
||||
public final class LegalPageHtml {
|
||||
|
||||
private static final Parser PARSER = Parser.builder().build();
|
||||
private static final HtmlRenderer RENDERER = HtmlRenderer.builder().escapeHtml(true).build();
|
||||
|
||||
private LegalPageHtml() {
|
||||
}
|
||||
|
||||
/** 生成完整页面。语言只用于 html lang 属性。 */
|
||||
public static String render(String lang, String markdown) {
|
||||
String body = RENDERER.render(PARSER.parse(markdown == null ? "" : markdown));
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="%s">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title></title>
|
||||
<style>
|
||||
body{margin:0;background:#111;color:#f4f4f5;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
main{max-width:720px;margin:0 auto;padding:28px 20px 48px}
|
||||
article>*:first-child{margin-top:0}
|
||||
article p{margin:0 0 12px}
|
||||
article h1,article h2,article h3{font-size:18px;line-height:1.4;margin:20px 0 8px}
|
||||
article a{color:#ff6b2c}
|
||||
article ul,article ol{margin:0 0 12px;padding-left:1.2em}
|
||||
article code{font-family:ui-monospace,monospace}
|
||||
article pre{overflow:auto;padding:12px;background:#1c1c1e;border-radius:8px}
|
||||
</style>
|
||||
</head>
|
||||
<body><main><article>%s</article></main></body>
|
||||
</html>
|
||||
""".formatted(escape(lang), body);
|
||||
}
|
||||
|
||||
/** 转义 HTML 特殊字符。 */
|
||||
public static String escape(String raw) {
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return raw.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 协议种类、公开路径和缺省文案。路径按语言区分,例如 /privacy/zh、/terms/en。
|
||||
*/
|
||||
public final class LegalPages {
|
||||
|
||||
public static final String TERMS = "terms";
|
||||
public static final String PRIVACY = "privacy";
|
||||
public static final String PATH_TERMS = "terms";
|
||||
public static final String PATH_PRIVACY = "privacy";
|
||||
public static final List<String> DOC_CODES = List.of(TERMS, PRIVACY);
|
||||
public static final List<String> LANGS = List.of("zh", "en", "id");
|
||||
public static final int TITLE_MAX = 80;
|
||||
public static final int CONTENT_MAX = 100000;
|
||||
|
||||
private LegalPages() {
|
||||
}
|
||||
|
||||
/** 公开路径对应的文档编码。无法识别时返回 null。 */
|
||||
public static String docCodeOfPath(String path) {
|
||||
if (PATH_PRIVACY.equals(path)) {
|
||||
return PRIVACY;
|
||||
}
|
||||
if (PATH_TERMS.equals(path)) {
|
||||
return TERMS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 后台还没保存时页面上使用的 Markdown 正文。 */
|
||||
public static String defaultContent(String lang) {
|
||||
return switch (lang) {
|
||||
case "en" -> "This page is published by FruitShorts and can be edited as Markdown in the admin console.";
|
||||
case "id" -> "Halaman ini dipublikasikan oleh FruitShorts dan dapat diubah sebagai Markdown di konsol admin.";
|
||||
default -> "本页由水果短剧发布,可在管理后台用 Markdown 修改。";
|
||||
};
|
||||
}
|
||||
|
||||
public static String key(String docCode, String lang) {
|
||||
return docCode + ":" + lang;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 后台读写的一份协议。公开页面不走这个结构,直接返回 HTML。
|
||||
*/
|
||||
@Data
|
||||
public class LegalDocumentVO {
|
||||
|
||||
/** terms 或 privacy。 */
|
||||
private String docCode;
|
||||
|
||||
/** zh、en、id。 */
|
||||
private String lang;
|
||||
|
||||
private String title;
|
||||
private String content;
|
||||
}
|
||||
Reference in new issue
Block a user