From 22edf34ff107cf302b11a8f57475b66b171c049f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:34:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(system):=20=E6=94=AF=E6=8C=81=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E7=BB=B4=E6=8A=A4=E4=B8=89=E8=AF=AD=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LegalDocumentAdminController.java | 47 +++++++ .../app/controller/LegalPageController.java | 44 ++++++ .../app/service/LegalPageHtmlTest.java | 33 +++++ fruitshorts-module-system/pom.xml | 5 + .../system/dal/entity/LegalDocument.java | 31 +++++ .../dal/mapper/LegalDocumentMapper.java | 10 ++ .../system/service/LegalDocumentService.java | 125 ++++++++++++++++++ .../system/service/LegalPageHtml.java | 56 ++++++++ .../system/service/LegalPages.java | 47 +++++++ .../system/vo/LegalDocumentVO.java | 21 +++ sql/patch-2026-10-23-legal-documents.sql | 40 ++++++ sql/schema.sql | 11 ++ 12 files changed, 470 insertions(+) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LegalDocumentAdminController.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/LegalPageController.java create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/LegalPageHtmlTest.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/LegalDocument.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/LegalDocumentMapper.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalDocumentService.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPageHtml.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPages.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LegalDocumentVO.java create mode 100644 sql/patch-2026-10-23-legal-documents.sql diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LegalDocumentAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LegalDocumentAdminController.java new file mode 100644 index 0000000..506a15d --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LegalDocumentAdminController.java @@ -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() { + return Result.ok(legalDocumentService.list()); + } + + @Operation(summary = "保存协议") + @PutMapping + @PreAuthorize("@ss.hasPermission('system:role')") + @OperationLog(module = "后台管理", action = "修改服务条款或隐私协议") + public Result save(@RequestBody List items) { + legalDocumentService.save(items); + return Result.ok(); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/LegalPageController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/LegalPageController.java new file mode 100644 index 0000000..e65f00b --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/LegalPageController.java @@ -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 privacy(@PathVariable String lang) { + return page(LegalPages.PATH_PRIVACY, lang); + } + + /** 服务条款。语言只接受 zh、en、id。 */ + @GetMapping("/" + LegalPages.PATH_TERMS + "/{lang}") + public ResponseEntity terms(@PathVariable String lang) { + return page(LegalPages.PATH_TERMS, lang); + } + + private ResponseEntity 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); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/LegalPageHtmlTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/LegalPageHtmlTest.java new file mode 100644 index 0000000..2a8b1d7 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/LegalPageHtmlTest.java @@ -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\n\n**粗体**"); + + assertTrue(html.contains("")); + assertFalse(html.contains("

")); + assertTrue(html.contains("

第一行

")); + assertTrue(html.contains("<script>")); + assertTrue(html.contains("粗体")); + } + + @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")); + } +} diff --git a/fruitshorts-module-system/pom.xml b/fruitshorts-module-system/pom.xml index 6021e51..b6f09a5 100644 --- a/fruitshorts-module-system/pom.xml +++ b/fruitshorts-module-system/pom.xml @@ -21,5 +21,10 @@ com.fruitshorts fruitshorts-framework + + org.commonmark + commonmark + 0.22.0 + diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/LegalDocument.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/LegalDocument.java new file mode 100644 index 0000000..18bf640 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/LegalDocument.java @@ -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; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/LegalDocumentMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/LegalDocumentMapper.java new file mode 100644 index 0000000..b168337 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/LegalDocumentMapper.java @@ -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 { +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalDocumentService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalDocumentService.java new file mode 100644 index 0000000..9c3565c --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalDocumentService.java @@ -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 list() { + Map stored = index(legalDocumentMapper.selectList(null)); + List 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 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.lambdaQuery() + .eq(LegalDocument::getDocCode, code) + .eq(LegalDocument::getLang, lang) + .last("limit 1")); + } + + private Map index(List rows) { + Map 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); + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPageHtml.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPageHtml.java new file mode 100644 index 0000000..6ef68bd --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPageHtml.java @@ -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 """ + + + + + + + + +
%s
+ + """.formatted(escape(lang), body); + } + + /** 转义 HTML 特殊字符。 */ + public static String escape(String raw) { + if (raw == null || raw.isEmpty()) { + return ""; + } + return raw.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPages.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPages.java new file mode 100644 index 0000000..2de0d94 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPages.java @@ -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 DOC_CODES = List.of(TERMS, PRIVACY); + public static final List 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; + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LegalDocumentVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LegalDocumentVO.java new file mode 100644 index 0000000..f0544eb --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LegalDocumentVO.java @@ -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; +} diff --git a/sql/patch-2026-10-23-legal-documents.sql b/sql/patch-2026-10-23-legal-documents.sql new file mode 100644 index 0000000..34ea9ad --- /dev/null +++ b/sql/patch-2026-10-23-legal-documents.sql @@ -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; diff --git a/sql/schema.sql b/sql/schema.sql index ad7c011..3eed332 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -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,