feat(system): 支持后台维护三语协议

This commit is contained in:
Zhou Weidong committed 2026-10-23 12:34:13 +08:00
1 parent 16ba79a336
commit 22edf34ff1
12 files changed
+470

No files matched your search

@@ -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();
}
}
@@ -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("&lt;script&gt;"));
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"));
}
}
+5
View File
@@ -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>
@@ -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;
}
@@ -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> {
}
@@ -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);
}
}
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -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;
}
}
@@ -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;
}
+40
View File
@@ -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;
+11
View File
@@ -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,