feat(system): 支持后台维护三语协议
This commit is contained in:
1 parent
16ba79a336
commit
22edf34ff1
12 files changed
+470
No files matched your search
+47
@@ -0,0 +1,47 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.system.service.LegalDocumentService;
|
||||
import com.fruitshorts.system.vo.LegalDocumentVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台维护服务条款和隐私协议。公开页面由客户端服务按语言输出。
|
||||
*/
|
||||
@Tag(name = "后台-协议")
|
||||
@RestController
|
||||
@RequestMapping("/admin/legal-documents")
|
||||
@RequiredArgsConstructor
|
||||
public class LegalDocumentAdminController {
|
||||
|
||||
private final LegalDocumentService legalDocumentService;
|
||||
|
||||
@Operation(summary = "协议列表(服务条款、隐私协议,各含中英印尼语)")
|
||||
@GetMapping
|
||||
@PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')")
|
||||
public Result<List<LegalDocumentVO>> list() {
|
||||
return Result.ok(legalDocumentService.list());
|
||||
}
|
||||
|
||||
@Operation(summary = "保存协议")
|
||||
@PutMapping
|
||||
@PreAuthorize("@ss.hasPermission('system:role')")
|
||||
@OperationLog(module = "后台管理", action = "修改服务条款或隐私协议")
|
||||
public Result<Void> save(@RequestBody List<LegalDocumentVO> items) {
|
||||
legalDocumentService.save(items);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.system.service.LegalDocumentService;
|
||||
import com.fruitshorts.system.service.LegalPages;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 协议公开页。客户端按当前语言打开 /privacy/zh 或 /terms/en,不需要登录。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class LegalPageController {
|
||||
|
||||
private static final MediaType HTML = MediaType.parseMediaType("text/html;charset=UTF-8");
|
||||
|
||||
private final LegalDocumentService legalDocumentService;
|
||||
|
||||
/** 隐私协议。语言只接受 zh、en、id。 */
|
||||
@GetMapping("/" + LegalPages.PATH_PRIVACY + "/{lang}")
|
||||
public ResponseEntity<String> privacy(@PathVariable String lang) {
|
||||
return page(LegalPages.PATH_PRIVACY, lang);
|
||||
}
|
||||
|
||||
/** 服务条款。语言只接受 zh、en、id。 */
|
||||
@GetMapping("/" + LegalPages.PATH_TERMS + "/{lang}")
|
||||
public ResponseEntity<String> terms(@PathVariable String lang) {
|
||||
return page(LegalPages.PATH_TERMS, lang);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> page(String path, String lang) {
|
||||
String html = legalDocumentService.render(path, lang);
|
||||
if (html == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok().contentType(HTML).body(html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.service;
|
||||
|
||||
import com.fruitshorts.system.service.LegalPageHtml;
|
||||
import com.fruitshorts.system.service.LegalPages;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class LegalPageHtmlTest {
|
||||
|
||||
@Test
|
||||
void should_render_markdown_without_an_extra_title() {
|
||||
String html = LegalPageHtml.render("zh", "第一行\n\n<script>alert(1)</script>\n\n**粗体**");
|
||||
|
||||
assertTrue(html.contains("<html lang=\"zh\">"));
|
||||
assertFalse(html.contains("<h1>"));
|
||||
assertTrue(html.contains("<p>第一行</p>"));
|
||||
assertTrue(html.contains("<script>"));
|
||||
assertTrue(html.contains("<strong>粗体</strong>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_map_public_paths_to_document_codes() {
|
||||
assertEquals(LegalPages.PRIVACY, LegalPages.docCodeOfPath("privacy"));
|
||||
assertEquals(LegalPages.TERMS, LegalPages.docCodeOfPath("terms"));
|
||||
assertNull(LegalPages.docCodeOfPath("other"));
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,10 @@
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-framework</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark</artifactId>
|
||||
<version>0.22.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+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;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Author: fruit.com.cn
|
||||
-- 服务条款与隐私协议:三语正文 + 后台菜单
|
||||
|
||||
CREATE TABLE IF NOT EXISTS legal_document (
|
||||
id BIGINT PRIMARY KEY,
|
||||
doc_code VARCHAR(16) NOT NULL,
|
||||
lang VARCHAR(8) NOT NULL,
|
||||
title VARCHAR(128) NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_legal_document_code_lang ON legal_document (doc_code, lang);
|
||||
COMMENT ON TABLE legal_document IS '服务条款与隐私协议';
|
||||
|
||||
INSERT INTO legal_document (id, doc_code, lang, title, content) VALUES
|
||||
(910001, 'terms', 'zh', '', '本页由水果短剧发布,可在管理后台用 Markdown 修改。'),
|
||||
(910002, 'terms', 'en', '', 'This page is published by FruitShorts and can be edited as Markdown in the admin console.'),
|
||||
(910003, 'terms', 'id', '', 'Halaman ini dipublikasikan oleh FruitShorts dan dapat diubah sebagai Markdown di konsol admin.'),
|
||||
(910004, 'privacy', 'zh', '', '本页由水果短剧发布,可在管理后台用 Markdown 修改。'),
|
||||
(910005, 'privacy', 'en', '', 'This page is published by FruitShorts and can be edited as Markdown in the admin console.'),
|
||||
(910006, 'privacy', 'id', '', 'Halaman ini dipublikasikan oleh FruitShorts dan dapat diubah sebagai Markdown di konsol admin.')
|
||||
ON CONFLICT (doc_code, lang) DO NOTHING;
|
||||
|
||||
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
|
||||
permission_code, type, always_show, affix, sort) VALUES
|
||||
(38, 6, '协议设置', 'legal-documents', 'legal-documents',
|
||||
'views/systemManagement/LegalDocumentView', '', '',
|
||||
'system:role', 'MENU', 0, 0, 8)
|
||||
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title,
|
||||
route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name,
|
||||
component = EXCLUDED.component, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon,
|
||||
permission_code = EXCLUDED.permission_code, type = EXCLUDED.type,
|
||||
always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
|
||||
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
SELECT r.id * 1000000 + m.id, r.id, m.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_menu m
|
||||
WHERE r.code = 'super_admin' AND m.id = 38
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -127,6 +127,17 @@ CREATE TABLE IF NOT EXISTS sys_config (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_key ON sys_config (config_key);
|
||||
COMMENT ON TABLE sys_config IS '系统配置';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS legal_document (
|
||||
id BIGINT PRIMARY KEY,
|
||||
doc_code VARCHAR(16) NOT NULL, -- terms / privacy
|
||||
lang VARCHAR(8) NOT NULL, -- zh / en / id
|
||||
title VARCHAR(128) NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_legal_document_code_lang ON legal_document (doc_code, lang);
|
||||
COMMENT ON TABLE legal_document IS '服务条款与隐私协议';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_operation_log (
|
||||
id BIGINT PRIMARY KEY,
|
||||
admin_user_id BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
Reference in new issue
Block a user