commit 99cd26b3d27e8716c9491c02d85ad77e067ebbd6 Author: chenxing Date: Mon Sep 7 09:09:39 2026 +0800 init local code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0a7c30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +target/ +*.class +*.log +logs/ +.idea/ +*.iml +.vscode/ +.DS_Store +# 前端构建产物拷入目录(见 front README) +src/main/resources/static/admin/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f79498 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +# CMS 后端镜像(对齐平台 sub-app 打包约定:fat jar + JRE8) +FROM openjdk:8-jre-slim +WORKDIR /app +ENV TZ=Asia/Shanghai +COPY target/cms.jar /app/cms.jar +EXPOSE 8072 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/cms.jar"] diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c247a1b --- /dev/null +++ b/pom.xml @@ -0,0 +1,141 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.yunhai + cms + 0.1 + cms + yh-platform 高校门户 CMS(管理端 API + Thymeleaf 门户 SSR) + + 1.8 + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + org.thymeleaf.extras + thymeleaf-extras-java8time + + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + + + + org.springframework.boot + spring-boot-starter-validation + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + org.projectlombok + lombok + 1.18.36 + true + + + mysql + mysql-connector-java + 8.0.33 + runtime + + + + org.jsoup + jsoup + 1.17.2 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-data-redis + test + + + + + cms + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Test.java + **/*IT.java + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + diff --git a/sql/cms_seed_local.sql b/sql/cms_seed_local.sql new file mode 100644 index 0000000..b7a14f1 --- /dev/null +++ b/sql/cms_seed_local.sql @@ -0,0 +1,122 @@ +-- ============================================================ +-- bsch_cms 本地种子数据(纯 SQL 版,2026-09-07 重写) +-- 结构对齐 local-debug/build/day3-seed.cjs(同一份演示数据的 SQL 等价物): +-- 1 站点 demo / 10 栏目(列表 7+单页 2+外链 1)/ 18 文章(13 发布含 3 置顶、3 草稿、2 下架) +-- / 3 轮播(跳文章/栏目/外链各一)/ 6 友链(3 组)/ 8 附件(6 图 + 2 文档) +-- 与 cjs 版的差异(有意):cjs 正文图走真实上传接口(PNG,uuid 文件名); +-- 本 SQL 引用静态种子 SVG(front/mock/seed-assets.cjs 生成于 D:/bsch/cms/upload/seed/), +-- art-1/art-2 在媒体/教学栏目复用(静态资产仅 6 张场景图)。 +-- 约定(B-1 / D3-8 / D6-1): +-- * 上传地址一律相对路径 /cms/upload/... 入库,渲染期按访问域名补基准(禁绝对 URL) +-- * 不配 public_base_url / domain(未配置=按请求派生,不写死 localhost) +-- * 已废弃键 footer(死键)不出现;页脚= contact/copyright/beian +-- 前置:cms_* 六表为空(Hibernate 自建表结构);种子账号 cmssuper/cmsschool(sch_id=990001) +-- 清理:DELETE FROM cms_attachment; DELETE FROM cms_banner; DELETE FROM cms_link; +-- DELETE FROM cms_article; DELETE FROM cms_category; DELETE FROM cms_site; +-- 验证:2026-09-07 本地 MySQL 8.0.45 scratch 库(结构同 bsch_cms)整文件执行通过 +-- ============================================================ + +-- 1. 站点(config_json 五键与 day3-seed.cjs 一致;logo 相对路径) +INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES +(1, 'demo', 0, 990001, '云海大学', '云海大学门户网站', 'default', + '{"logo": "/cms/upload/seed/logo-univ.svg", "contact": "地址:云海市学府路 1 号 · 电话:010-88888888", "copyright": "云海大学 版权所有 © 2026(演示数据)", "beian": "京ICP备00000000号-1 · 京公网安备110000000000号", "seo_description": "云海大学门户网站演示站点(day3 验收演示数据)"}', + 1, 'cmssuper', NOW()); + +-- 2. 栏目(三种类型各就位:列表 7(含 2 父)/ 单页 2 / 外链 1;排序与 cjs 相同) +INSERT INTO cms_category (id, site_id, parent_id, name, type, link_url, content, sort_no, seo_title, status, create_by, create_time) VALUES +(1, 1, 0, '学校概况', 2, NULL, '

学校简介

云海大学创建于 1958 年,是一所以工为主、多学科协调发展的综合性大学。(演示数据)

', 1, NULL, 1, 'cmssuper', NOW()), +(2, 1, 0, '新闻中心', 1, NULL, NULL, 2, NULL, 1, 'cmssuper', NOW()), +(3, 1, 2, '通知公告', 1, NULL, NULL, 1, NULL, 1, 'cmssuper', NOW()), +(4, 1, 2, '校园动态', 1, NULL, NULL, 2, NULL, 1, 'cmssuper', NOW()), +(5, 1, 2, '媒体报道', 1, NULL, NULL, 3, NULL, 1, 'cmssuper', NOW()), +(6, 1, 0, '教学科研', 1, NULL, NULL, 3, NULL, 1, 'cmssuper', NOW()), +(7, 1, 6, '教学动态', 1, NULL, NULL, 1, NULL, 1, 'cmssuper', NOW()), +(8, 1, 6, '科研成果', 1, NULL, NULL, 2, NULL, 1, 'cmssuper', NOW()), +(9, 1, 0, '招生网', 3, 'https://zs.example.edu.cn', NULL, 4, NULL, 1, 'cmssuper', NOW()), +(10, 1, 0, '联系我们', 2, NULL, '

地址:云海市学府路 1 号

邮编:100000

电话:010-88888888

', 5, NULL, 1, 'cmssuper', NOW()); + +-- 3. 文章(5 个叶子列表栏目 × 3~4 篇;状态组合与 day3-seed.cjs 逐篇一致: +-- 发布 13(含置顶 3)/ 草稿 3 / 下架 2;发布后下架者保留 publish_time) +-- 图文篇配图/art-N.svg、封面/cover-*.svg 均相对路径(B-1) +INSERT INTO cms_article (id, site_id, category_id, title, summary, content, cover_image, author_account, author_name, source, status, topmost, publish_time, view_count, create_by, create_time) VALUES +(1, 1, 3, '关于 2026 年秋季学期开学注册安排的通知', '现将 2026 年秋季学期开学注册有关事项通知如下。', + '

关于 2026 年秋季学期开学注册安排的通知——演示数据正文。本篇配图为种子静态资源。

各单位、全体同学:2026 年秋季学期开学注册定于 9 月 6 日至 7 日进行,请按各学院通知分批办理。

演示配图

第二段正文,验证排版与图片渲染。

', + NULL, 'cmssuper', '张老师', '', 1, 1, NOW() - INTERVAL 1 DAY, 1411, 'cmssuper', NOW() - INTERVAL 1 DAY), +(2, 1, 3, '图书馆国庆节期间开放时间调整公告', NULL, + '

图书馆开放时间调整——演示数据正文。本篇配图为种子静态资源。

国庆节期间(10 月 1 日至 7 日)图书馆开放时间调整为 9:00-16:00,10 月 8 日起恢复正常。

演示配图

第二段正文,验证排版与图片渲染。

', + NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 3 DAY, 499, 'cmssuper', NOW() - INTERVAL 2 DAY), +(3, 1, 3, '校园网络升级维护通知(草稿)', NULL, + '

校园网络升级维护通知(草稿)——演示数据正文。

', + NULL, 'cmssuper', '张老师', '', 0, 0, NULL, 680, 'cmssuper', NOW() - INTERVAL 1 DAY), +(4, 1, 3, '关于开展 2026 级新生入学教育系列活动的通知', NULL, + '

关于开展 2026 级新生入学教育系列活动的通知——演示数据正文。本篇配图为种子静态资源。

演示配图

第二段正文,验证排版与图片渲染。

', + NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 6 DAY, 1899, 'cmssuper', NOW() - INTERVAL 2 DAY), +(5, 1, 4, '我校成功举办第 38 届田径运动会', '为期三天的校运会圆满落幕,16 个学院代表队参赛。', + '

我校成功举办第 38 届田径运动会——演示数据正文。本篇配图为种子静态资源。

为期三天的校运会圆满落幕,16 个学院代表队、1200 余名运动员参赛,打破校纪录 3 项。

演示配图

第二段正文,验证排版与图片渲染。

', + '/cms/upload/seed/cover-运动会.svg', 'cmssuper', '张老师', '', 1, 1, NOW() - INTERVAL 2 DAY, 134, 'cmssuper', NOW() - INTERVAL 5 DAY), +(6, 1, 4, '2026 年迎新晚会精彩回顾', NULL, + '

2026 年迎新晚会精彩回顾——演示数据正文。本篇配图为种子静态资源。

演示配图

第二段正文,验证排版与图片渲染。

', + '/cms/upload/seed/cover-迎新晚会.svg', 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 5 DAY, 1497, 'cmssuper', NOW() - INTERVAL 5 DAY), +(7, 1, 4, '大学生创新创业大赛校内选拔赛落幕', NULL, + '

大学生创新创业大赛校内选拔赛落幕——演示数据正文。本篇配图为种子静态资源。

演示配图

第二段正文,验证排版与图片渲染。

', + '/cms/upload/seed/cover-创新创业.svg', 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 9 DAY, 299, 'cmssuper', NOW() - INTERVAL 7 DAY), +(8, 1, 4, '2025 年优秀学生表彰大会报道(已下架)', NULL, + '

2025 年优秀学生表彰大会报道(已下架)——演示数据正文。

', + NULL, 'cmssuper', '张老师', '', 2, 0, NOW() - INTERVAL 40 DAY, 1123, 'cmssuper', NOW() - INTERVAL 40 DAY), +(9, 1, 5, '《云海日报》:云海大学深耕产教融合一线观察', NULL, + '

《云海日报》:云海大学深耕产教融合一线观察——演示数据正文。本篇配图为种子静态资源(art-1 复用)。

演示配图

第二段正文,验证排版与图片渲染。

', + NULL, 'cmssuper', '张老师', '云海日报', 1, 0, NOW() - INTERVAL 4 DAY, 566, 'cmssuper', NOW() - INTERVAL 6 DAY), +(10, 1, 5, '市电视台专访我校智能制造实验室', NULL, + '

市电视台专访我校智能制造实验室——演示数据正文。

', + NULL, 'cmssuper', '张老师', '云海电视台', 1, 0, NOW() - INTERVAL 12 DAY, 331, 'cmssuper', NOW() - INTERVAL 9 DAY), +(11, 1, 5, '志愿服务月活动启动(草稿)', NULL, + '

志愿服务月活动启动(草稿)——演示数据正文。

', + NULL, 'cmssuper', '张老师', '', 0, 0, NULL, 108, 'cmssuper', NOW() - INTERVAL 8 DAY), +(12, 1, 7, '我校 12 门课程获评省级一流本科课程', NULL, + '

我校 12 门课程获评省级一流本科课程——演示数据正文。本篇配图为种子静态资源(art-2 复用)。

演示配图

第二段正文,验证排版与图片渲染。

', + NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 7 DAY, 208, 'cmssuper', NOW() - INTERVAL 11 DAY), +(13, 1, 7, '关于组织申报 2027 年教改项目的预通知', NULL, + '

关于组织申报 2027 年教改项目的预通知——演示数据正文。

', + NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 15 DAY, 97, 'cmssuper', NOW() - INTERVAL 13 DAY), +(14, 1, 7, '人工智能赋能课堂教学改革研讨会举行', NULL, + '

人工智能赋能课堂教学改革研讨会举行——演示数据正文。

', + NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 10 DAY, 156, 'cmssuper', NOW() - INTERVAL 10 DAY), +(15, 1, 8, '材料学院团队在《Nature》子刊发表最新成果', '该研究首次揭示了新型钙钛矿材料的界面稳定性机制。', + '

材料学院团队在《Nature》子刊发表最新成果——演示数据正文。

该研究首次揭示了新型钙钛矿材料的界面稳定性机制,为下一代光伏器件提供理论基础。

', + NULL, 'cmssuper', '张老师', '', 1, 1, NOW() - INTERVAL 2 DAY, 1730, 'cmssuper', NOW() - INTERVAL 14 DAY), +(16, 1, 8, '我校获国家重点研发计划项目立项 3 项', NULL, + '

我校获国家重点研发计划项目立项 3 项——演示数据正文。

', + NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 18 DAY, 145, 'cmssuper', NOW() - INTERVAL 16 DAY), +(17, 1, 8, '大模型教育应用白皮书编制启动(草稿)', NULL, + '

大模型教育应用白皮书编制启动(草稿)——演示数据正文。

', + NULL, 'cmssuper', '张老师', '', 0, 0, NULL, 12, 'cmssuper', NOW() - INTERVAL 3 DAY), +(18, 1, 8, '校企合作联合实验室签约(已下架)', NULL, + '

校企合作联合实验室签约(已下架)——演示数据正文。

', + NULL, 'cmssuper', '张老师', '', 2, 0, NOW() - INTERVAL 30 DAY, 87, 'cmssuper', NOW() - INTERVAL 30 DAY); + +-- 4. 轮播(3 张:跳栏目 / 跳文章 / 跳外链;第 2 张目标=迎新晚会(cjs 版 publishedIds[4] 的等价行)) +INSERT INTO cms_banner (id, site_id, position, title, image_url, link_type, link_value, sort_no, status, create_by, create_time) VALUES +(1, 1, 'home', '笃学慎思 明辨笃行', '/cms/upload/seed/banner-1.svg', 2, '2', 1, 1, 'cmssuper', NOW()), +(2, 1, 'home', '第 38 届田径运动会', '/cms/upload/seed/banner-2.svg', 1, '6', 2, 1, 'cmssuper', NOW()), +(3, 1, 'home', '2026 年招生咨询', '/cms/upload/seed/banner-3.svg', 3, 'https://zs.example.edu.cn', 3, 1, 'cmssuper', NOW()); + +-- 5. 友情链接(3 组 × 2 条;logo 相对路径) +INSERT INTO cms_link (id, site_id, group_name, name, logo_url, link_url, sort_no, status, create_by, create_time) VALUES +(1, 1, '上级部门', '教育部', '/cms/upload/seed/logo-1.svg', 'http://www.moe.gov.cn', 1, 1, 'cmssuper', NOW()), +(2, 1, '上级部门', '省教育厅', '/cms/upload/seed/logo-2.svg', 'http://jyt.example.cn', 2, 1, 'cmssuper', NOW()), +(3, 1, '兄弟院校', '云海理工大学', '/cms/upload/seed/logo-3.svg', 'https://www.yhlg.edu.cn', 3, 1, 'cmssuper', NOW()), +(4, 1, '兄弟院校', '云海师范大学', '/cms/upload/seed/logo-4.svg', 'https://www.yhsf.edu.cn', 4, 1, 'cmssuper', NOW()), +(5, 1, '常用服务', '教务系统', '/cms/upload/seed/logo-6.svg', 'https://jw.yunhai-demo.edu.cn', 5, 1, 'cmssuper', NOW()), +(6, 1, '常用服务', '图书馆', '/cms/upload/seed/logo-7.svg', 'https://lib.yunhai-demo.edu.cn', 6, 1, 'cmssuper', NOW()); + +-- 6. 附件(对应 cjs 版 8 张真实上传的等价行:6 张种子图(不绑文章=孤儿,演示孤儿清理) +-- + 2 个 PDF(绑定文章 1/5,演示文档附件);web_url 一律相对路径(B-1)) +INSERT INTO cms_attachment (id, site_id, article_id, file_name, file_path, web_url, file_size, mime_type, create_by, create_time) VALUES +(1, 1, NULL, 'scene-1.svg', 'seed/art-1.svg', '/cms/upload/seed/art-1.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 1 DAY), +(2, 1, NULL, 'scene-2.svg', 'seed/art-2.svg', '/cms/upload/seed/art-2.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 2 DAY), +(3, 1, NULL, 'scene-3.svg', 'seed/art-3.svg', '/cms/upload/seed/art-3.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 6 DAY), +(4, 1, NULL, 'scene-4.svg', 'seed/art-4.svg', '/cms/upload/seed/art-4.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 2 DAY), +(5, 1, NULL, 'scene-5.svg', 'seed/art-5.svg', '/cms/upload/seed/art-5.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 5 DAY), +(6, 1, NULL, 'scene-6.svg', 'seed/art-6.svg', '/cms/upload/seed/art-6.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 9 DAY), +(7, 1, 1, '2026秋季学期校历.pdf', 'seed/2026校历.pdf', '/cms/upload/seed/2026校历.pdf', 620, 'application/pdf', 'cmssuper', NOW() - INTERVAL 1 DAY), +(8, 1, 5, '运动会秩序册.pdf', 'seed/运动会秩序册.pdf', '/cms/upload/seed/运动会秩序册.pdf', 630, 'application/pdf', 'cmssuper', NOW() - INTERVAL 5 DAY); diff --git a/sql/edge_seed_visual.sql b/sql/edge_seed_visual.sql new file mode 100644 index 0000000..33a56df --- /dev/null +++ b/sql/edge_seed_visual.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- 视觉精修边界状态种子(2026-09-06,docs/decisions.md 决策 D0-2) +-- 目的:edge 站覆盖 空列表/无封面/超长标题/单轮播/无友链/富文本详情 等边界; +-- demo 站补全页脚三键 + 院系子站(header 专业院系下拉);下架手工测试轮播"213"。 +-- 清理(恢复原状): +-- UPDATE cms_banner SET status=1 WHERE id=5; +-- UPDATE cms_site SET config_json=JSON_REMOVE(config_json,'$.contact','$.copyright','$.beian') WHERE code='demo'; +-- DELETE FROM cms_site WHERE code IN ('edge','jdx','xxgc'); +-- DELETE FROM cms_category WHERE site_id IN (SELECT id FROM cms_site WHERE code='edge') OR id IN (61,62,63); +-- DELETE FROM cms_article WHERE site_id IN (SELECT id FROM cms_site WHERE code='edge'); +-- DELETE FROM cms_banner WHERE title='边界站唯一轮播'; +-- (注:site 删除需先删子表行,上面顺序已按依赖排列;jdx/xxgc 的栏目/文章见 61,62,63) +-- ============================================================ + +-- 1) demo:页脚三键补全(模板读 contact/copyright/beian)+ 下架测试轮播 +UPDATE cms_site SET config_json = JSON_SET(config_json, + '$.contact', '地址:云海市学府路 1 号 · 电话:010-88888888', + '$.copyright', '云海大学 版权所有', + '$.beian', '京ICP备00000000号-1 · 京公网安备110000000000号') +WHERE code = 'demo'; +UPDATE cms_banner SET status = 0 WHERE id = 5; + +-- 2) 院系子站(demo 的子站:header 出"专业院系"下拉;不配 public_base_url=按请求派生) +INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES +(6, 'jdx', 1, 990001, '云海大学', '机电学院', 'default', '{"copyright": "机电学院"}', 1, 'cmssuper', NOW()), +(7, 'xxgc', 1, 990001, '云海大学', '信息工程学院', 'default', '{"copyright": "信息工程学院"}', 1, 'cmssuper', NOW()); +INSERT INTO cms_category (id, site_id, parent_id, name, type, link_url, content, sort_no, status, create_by, create_time) VALUES +(61, 6, 0, '学院概况', 2, NULL, '

机电学院(视觉精修种子)。

', 1, 1, 'cmssuper', NOW()), +(62, 6, 0, '学院新闻', 1, NULL, NULL, 2, 1, 'cmssuper', NOW()), +(63, 7, 0, '学院概况', 2, NULL, '

信息工程学院(视觉精修种子)。

', 1, 1, 'cmssuper', NOW()); + +-- 3) edge 边界站:单轮播 / 空列表栏目 / 图文栏目(无封面+超长标题+富文本) / 单页(富+空) +INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES +(5, 'edge', 0, 990001, '云海大学', '边界状态演示站', 'default', + '{"logo": "/cms/upload/seed/logo-univ.svg", "contact": "边界站 · 视觉回归用", "copyright": "边界状态演示站", "beian": "无备案(演示数据)", "seo_description": "门户视觉边界状态演示站"}', + 1, 'cmssuper', NOW()); +INSERT INTO cms_category (id, site_id, parent_id, name, type, link_url, content, sort_no, status, create_by, create_time) VALUES +(51, 5, 0, '通知公告', 1, NULL, NULL, 1, 1, 'cmssuper', NOW()), +(52, 5, 0, '图文快讯', 1, NULL, NULL, 2, 1, 'cmssuper', NOW()), +(53, 5, 0, '学校简介', 2, NULL, + '

学校概况

边界状态演示站用于验证门户在极端内容形态下的视觉稳定性。本页包含标题、段落、图片、表格、列表、引用等全部正文元素。

校园示意图
校园示意图(640×360 演示图)

基本数据

指标数值备注
占地面积3000 亩含新校区
全日制在校生32000 人本科生 26000
专任教师1800 人含院士 12 名
笃学慎思,明辨笃行——演示校训。

以上内容均为本地演示数据,用于视觉回归测试。

', + 3, 1, 'cmssuper', NOW()), +(54, 5, 0, '空白单页', 2, NULL, '', 4, 1, 'cmssuper', NOW()); + +INSERT INTO cms_banner (site_id, position, title, image_url, link_type, link_value, status, sort_no, create_by, create_time) VALUES +(5, 'home', '边界站唯一轮播', '/cms/upload/seed/banner-2.svg', 1, NULL, 1, 1, 'cmssuper', NOW()); + +-- 超长标题(约 74 字) +INSERT INTO cms_article (site_id, category_id, title, summary, content, cover_image, author_account, author_name, source, status, topmost, publish_time, view_count, create_by, create_time) VALUES +(5, 52, '关于举办2026年云海大学"数智赋能高等教育高质量发展"系列学术论坛暨第三届校级教育教学成果奖申报动员培训会的通知公告', '超长标题边界样例——验证单行省略、列表行高与详情页标题换行的稳定性。', + '

超长标题边界样例正文。本篇用于验证:首页区块/列表页的单行省略、轮播与区块布局稳定性、详情页 H1 长标题换行与行高。

配图

', + '/cms/upload/seed/cover-运动会.svg', 'cmssuper', '边界测试', '', 1, 1, NOW() - INTERVAL 1 DAY, 42, 'cmssuper', NOW() - INTERVAL 1 DAY), +-- 无封面文章 +(5, 52, '这篇边界文章没有封面图——验证无封面样式的占位设计', '无封面边界样例:列表不得出现破图或空洞。', + '

无封面边界样例正文。列表与首页若展示封面,无封面文章必须呈现精心设计的占位形态而非空白或破图。

配图

', + NULL, 'cmssuper', '边界测试', '', 1, 0, NOW() - INTERVAL 2 DAY, 7, 'cmssuper', NOW() - INTERVAL 2 DAY), +-- 普通文章(有摘要) +(5, 52, '边界站常规文章样例', '常规形态对照样例。', + '

常规形态正文,作为边界样式的对照。

', + '/cms/upload/seed/cover-创新创业.svg', 'cmssuper', '边界测试', '边界站', 1, 0, NOW() - INTERVAL 3 DAY, 3, 'cmssuper', NOW() - INTERVAL 3 DAY); +-- edge 站不插入任何 cms_link → 友链整段隐藏边界 diff --git a/src/main/java/com/yunhai/cms/CmsApplication.java b/src/main/java/com/yunhai/cms/CmsApplication.java new file mode 100644 index 0000000..f5e3da7 --- /dev/null +++ b/src/main/java/com/yunhai/cms/CmsApplication.java @@ -0,0 +1,26 @@ +package com.yunhai.cms; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 云海平台高校门户 CMS。 + * + *

一个服务两副面孔(方案 §3.1): + *

+ * CAS 只拦 {@code /api/*}(yh.sso.url-pattern),门户必须匿名,勿改配置(方案 §7.1)。 + */ +@EnableCaching +@EnableScheduling +@SpringBootApplication +public class CmsApplication { + + public static void main(String[] args) { + SpringApplication.run(CmsApplication.class, args); + } +} diff --git a/src/main/java/com/yunhai/cms/common/CmsAuditListener.java b/src/main/java/com/yunhai/cms/common/CmsAuditListener.java new file mode 100644 index 0000000..e6eb608 --- /dev/null +++ b/src/main/java/com/yunhai/cms/common/CmsAuditListener.java @@ -0,0 +1,41 @@ +package com.yunhai.cms.common; + +import com.yunhai.cms.entity.BaseEntity; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * 审计字段自动填充(Notice NoticeAuditListener 同款模式)。 + * 账号取 ThreadLocal 中的 CAS 会话用户;门户匿名渲染、定时任务等无会话场景为 null。 + */ +@Slf4j +@Component +public class CmsAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + String account = currentAccount(); + LocalDateTime now = LocalDateTime.now(); + entity.setCreateBy(account); + entity.setCreateTime(now); + entity.setUpdateBy(account); + entity.setUpdateTime(now); + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateBy(currentAccount()); + entity.setUpdateTime(LocalDateTime.now()); + } + + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } +} diff --git a/src/main/java/com/yunhai/cms/config/CacheConfig.java b/src/main/java/com/yunhai/cms/config/CacheConfig.java new file mode 100644 index 0000000..1066beb --- /dev/null +++ b/src/main/java/com/yunhai/cms/config/CacheConfig.java @@ -0,0 +1,115 @@ +package com.yunhai.cms.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; + +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Caffeine 本地缓存装配(方案 §8.4 工程约束)。 + * + * + * 切 Redis 时仅替换本配置与 spring.cache.type(方案 §1.2/§12); + * yml 中 spring.cache.caffeine.spec 供默认管理器/演进期使用,本管理器按 CacheNames 显式装配。 + */ +@Configuration +@EnableCaching +public class CacheConfig implements CachingConfigurer { + + /** 抖动幅度:±60s */ + private static final Duration JITTER = Duration.ofSeconds(60); + /** null 哨兵 TTL */ + private static final Duration NULL_TTL = Duration.ofSeconds(30); + /** 抖动后 TTL 下限,避免抖出负数/过短 */ + private static final Duration MIN_TTL = Duration.ofSeconds(30); + + private final LoggingCacheErrorHandler errorHandler; + + public CacheConfig(LoggingCacheErrorHandler errorHandler) { + this.errorHandler = errorHandler; + } + + @Bean + @Override + public CacheManager cacheManager() { + SimpleCacheManager manager = new SimpleCacheManager(); + manager.setCaches(Arrays.asList( + new CaffeineCache(CacheNames.SITE, caffeine(Duration.ofMinutes(30)).build()), + new CaffeineCache(CacheNames.ARTICLE, caffeine(Duration.ofMinutes(5)).build()), + new CaffeineCache(CacheNames.LIST, caffeine(Duration.ofMinutes(5)).build()))); + return manager; + } + + @Override + public CacheErrorHandler errorHandler() { + return errorHandler; + } + + private Caffeine caffeine(Duration baseTtl) { + return Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfter(new JitterExpiry(baseTtl)); + } + + /** + * 按 TTL 基准 + 随机抖动的过期策略;null 哨兵固定 30s。 + */ + static class JitterExpiry implements Expiry { + + private final Duration baseTtl; + + JitterExpiry(Duration baseTtl) { + this.baseTtl = baseTtl; + } + + @Override + public long expireAfterCreate(@NonNull Object key, Object value, long currentTime) { + if (isNullValue(value)) { + return NULL_TTL.toNanos(); + } + long jitterMillis = ThreadLocalRandom.current() + .nextLong(-JITTER.toMillis(), JITTER.toMillis()); + Duration jittered = baseTtl.plusMillis(jitterMillis); + if (jittered.compareTo(MIN_TTL) < 0) { + jittered = MIN_TTL; + } + return jittered.toNanos(); + } + + @Override + public long expireAfterUpdate(@NonNull Object key, Object value, long currentTime, + long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(@NonNull Object key, Object value, long currentTime, + long currentDuration) { + return currentDuration; + } + + /** + * Spring 把 null 包成 NullValue 单例存入 Caffeine(Caffeine 本身不收 null)。 + * 用类名判断避免对 Spring 内部类硬编译依赖。 + */ + private boolean isNullValue(Object value) { + return value == null + || "org.springframework.cache.support.NullValue".equals(value.getClass().getName()); + } + } +} diff --git a/src/main/java/com/yunhai/cms/config/CacheNames.java b/src/main/java/com/yunhai/cms/config/CacheNames.java new file mode 100644 index 0000000..b35948a --- /dev/null +++ b/src/main/java/com/yunhai/cms/config/CacheNames.java @@ -0,0 +1,26 @@ +package com.yunhai.cms.config; + +/** + * 缓存名常量(方案 §8.4)。 + *

javadoc 即失效契约:CacheNames 与失效事件的对应关系集中在此,禁止散落魔法串。 + *

    + *
  • {@link #SITE}:siteCode → 站点+栏目树+轮播+友链快照,TTL 30min±60s; + * 失效事件 = 站点/栏目/轮播/友链 变更(SiteContentChangedEvent)
  • + *
  • {@link #ARTICLE}:articleId → 详情,TTL 5min±60s;articleId 不存在时缓存 null 哨兵 30s 防穿透; + * 失效事件 = 文章发布/修改/下架(ArticleChangedEvent / ArticlePublishedEvent)
  • + *
  • {@link #LIST}:siteId:categoryId:page → 列表页数据,TTL 5min±60s(null 哨兵同上); + * 失效事件 = 该站点文章发布/下架/修改
  • + *
+ */ +public final class CacheNames { + + /** 门户站点快照(首页数据源) */ + public static final String SITE = "cms:site"; + /** 门户文章详情 */ + public static final String ARTICLE = "cms:article"; + /** 门户栏目列表页 */ + public static final String LIST = "cms:list"; + + private CacheNames() { + } +} diff --git a/src/main/java/com/yunhai/cms/config/CmsAuthProperties.java b/src/main/java/com/yunhai/cms/config/CmsAuthProperties.java new file mode 100644 index 0000000..f382c87 --- /dev/null +++ b/src/main/java/com/yunhai/cms/config/CmsAuthProperties.java @@ -0,0 +1,30 @@ +package com.yunhai.cms.config; + +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.stereotype.Component; + +/** + * CMS 权限判定(对齐 UserBase GroupAuthzService#isSuperAdmin 与 UserTypeEnum 平台约定)。 + * + *

超管规则:{@code BoaUser.type == 1}(UserTypeEnum.SUPER_ADMIN,系统种子账号), + * 或 {@code BoaUser.id == 100000}(基石超级管理员账号,同 UserBase 内部判定)。 + * type 随认证链路经 BoaUserLoader 从 UserBase 换出,无需任何注入配置。 + * 学校管理员(type=2,持 ADMIN 角色)与学校领导不特判,统一走 + * {@code currentRole.schId → site.schId} 校级规则(方案 §7.2 规则②)。

+ */ +@Component +public class CmsAuthProperties { + + /** UserBase 基石超级管理员账号 id(DicUtil.SUPER_ADMIN_ID,平台约定常量) */ + private static final int PLATFORM_ROOT_USER_ID = 100000; + + public boolean isSuperAdmin(BoaUser user) { + if (user == null) { + return false; + } + if (user.getType() != null && user.getType() == 1) { + return true; + } + return user.getId() != null && user.getId() == PLATFORM_ROOT_USER_ID; + } +} diff --git a/src/main/java/com/yunhai/cms/config/JacksonConfig.java b/src/main/java/com/yunhai/cms/config/JacksonConfig.java new file mode 100644 index 0000000..4040111 --- /dev/null +++ b/src/main/java/com/yunhai/cms/config/JacksonConfig.java @@ -0,0 +1,28 @@ +package com.yunhai.cms.config; + +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + +import java.time.format.DateTimeFormatter; + +/** + * LocalDateTime 统一按 yyyy-MM-dd HH:mm:ss 序列化/反序列化 + * (yml 的 spring.jackson.date-format 不作用于 JSR310,需显式定制;UserHome JacksonConfig 同意图)。 + */ +@Configuration +public class JacksonConfig { + + private static final DateTimeFormatter DATE_TIME = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Bean + public Jackson2ObjectMapperBuilderCustomizer localDateTimeCustomizer() { + return builder -> builder + .serializers(new LocalDateTimeSerializer(DATE_TIME)) + .deserializers(new LocalDateTimeDeserializer(DATE_TIME)); + } +} diff --git a/src/main/java/com/yunhai/cms/config/LoggingCacheErrorHandler.java b/src/main/java/com/yunhai/cms/config/LoggingCacheErrorHandler.java new file mode 100644 index 0000000..3abf368 --- /dev/null +++ b/src/main/java/com/yunhai/cms/config/LoggingCacheErrorHandler.java @@ -0,0 +1,40 @@ +package com.yunhai.cms.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +/** + * 缓存异常降级(方案 §8.4):get 异常按 miss 返回 null,put/evict/clear 异常吞掉; + * 脏数据风险靠 TTL 自愈,只留 error 日志定位。 + */ +@Slf4j +@Component +public class LoggingCacheErrorHandler implements CacheErrorHandler { + + @Override + public void handleCacheGetError(@NonNull RuntimeException ex, @NonNull Cache cache, + @NonNull Object key) { + log.error("cache get error, treat as miss, cache={}, key={}", cache.getName(), key, ex); + } + + @Override + public void handleCachePutError(@NonNull RuntimeException ex, @NonNull Cache cache, + @NonNull Object key, @Nullable Object value) { + log.error("cache dirty data risk, TTL自愈, cache={}, key={}", cache.getName(), key, ex); + } + + @Override + public void handleCacheEvictError(@NonNull RuntimeException ex, @NonNull Cache cache, + @NonNull Object key) { + log.error("cache dirty data risk, TTL自愈, cache={}, key={}", cache.getName(), key, ex); + } + + @Override + public void handleCacheClearError(@NonNull RuntimeException ex, @NonNull Cache cache) { + log.error("cache dirty data risk, TTL自愈, cache={}", cache.getName(), ex); + } +} diff --git a/src/main/java/com/yunhai/cms/config/WebConfig.java b/src/main/java/com/yunhai/cms/config/WebConfig.java new file mode 100644 index 0000000..372704a --- /dev/null +++ b/src/main/java/com/yunhai/cms/config/WebConfig.java @@ -0,0 +1,56 @@ +package com.yunhai.cms.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.ForwardedHeaderFilter; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.io.File; + +/** + * Web 静态资源装配。 + * + *
    + *
  • {@code /upload/**} → FILE_PATH 本地磁盘(正文图片/轮播图/友链 Logo,双 key 约定方案 §7.3);
  • + *
  • {@code /admin/**} → 管理端 SPA(front 构建产物拷入 classpath:/static/admin/, + * hash 路由 + 相对 base,天然支持子路径部署);
  • + *
  • 根路径与 /admin 跳管理端入口。
  • + *
+ * 门户主题资产不在此处:按站点皮肤包解析,见 PortalAssetController。 + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${FILE_PATH:./upload/}") + private String filePath; + + /** + * X-Forwarded-Proto/Host/Port 头处理(B-2):反代(Caddy/nginx)形态下 + * request.getScheme()/getServerName()/getServerPort() 修正为对外值, + * 使 public_base_url/base-origin 均未配置时的请求派生 URL 不携带内网 scheme/端口。 + * 仅应在可信反代之后部署(直连暴露时该头可伪造,只影响派生形态展示,不影响数据)。 + */ + @Bean + public ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String root = filePath.endsWith("/") || filePath.endsWith("\\") ? filePath : filePath + "/"; + registry.addResourceHandler("/upload/**") + .addResourceLocations("file:" + new File(root).getAbsolutePath().replace('\\', '/') + "/"); + registry.addResourceHandler("/admin/**") + .addResourceLocations("classpath:/static/admin/"); + } + + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addRedirectViewController("/", "/admin/index.html"); + registry.addRedirectViewController("/admin", "/admin/index.html"); + registry.addRedirectViewController("/admin/", "/admin/index.html"); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/ApiParamExceptionHandler.java b/src/main/java/com/yunhai/cms/controller/api/ApiParamExceptionHandler.java new file mode 100644 index 0000000..1d0a3c5 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/ApiParamExceptionHandler.java @@ -0,0 +1,56 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.platform.apistd.ApiResponse; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +/** + * 管理端参数级异常翻译(体检 H-1/H-4 根因修复)。 + * + *

平台 GlobalExceptionHandler 只兜 @Valid 校验/BizException/全局 Exception, + * 三类 Spring MVC 参数绑定异常(缺参/类型不匹配/请求体不可读)落入兜底分支被 + * 报成 code=6 "内部服务错误"。本 advice 以更高优先级把它们翻译回 code=2 参数错误, + * 前端可提示、日志不再噪音;真正的业务/系统异常不受影响(仍走平台处理器,堆栈完整)。 + */ +@Slf4j +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice(basePackages = "com.yunhai.cms.controller.api") +public class ApiParamExceptionHandler { + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ApiResponse missingParam(MissingServletRequestParameterException e) { + log.warn("管理端缺必填参数: {}", e.getParameterName()); + return ApiResponse.fail(ErrorCode.PARAM_ERROR, "缺少必填参数:" + e.getParameterName()); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ApiResponse typeMismatch(MethodArgumentTypeMismatchException e) { + String expected = e.getRequiredType() == null ? "" : "(应为 " + e.getRequiredType().getSimpleName() + ")"; + log.warn("管理端参数类型不匹配: {}={}", e.getName(), e.getValue()); + return ApiResponse.fail(ErrorCode.PARAM_ERROR, "参数格式不正确:" + e.getName() + expected); + } + + /** JSON 解析失败:编码/语法/时间格式(jackson date-format yyyy-MM-dd HH:mm:ss) */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ApiResponse unreadable(HttpMessageNotReadableException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + String hint = cause.getMessage() == null ? "" : ": " + firstLine(cause.getMessage()); + log.warn("管理端请求体不可读 {}", firstLine(hint)); + return ApiResponse.fail(ErrorCode.PARAM_ERROR, + "请求体格式错误(JSON/UTF-8/时间格式,时间需 yyyy-MM-dd HH:mm:ss)"); + } + + private static String firstLine(String s) { + int i = s.indexOf('\n'); + String line = i < 0 ? s : s.substring(0, i); + return line.length() > 120 ? line.substring(0, 120) : line; + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/ArticleController.java b/src/main/java/com/yunhai/cms/controller/api/ArticleController.java new file mode 100644 index 0000000..70ffb38 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/ArticleController.java @@ -0,0 +1,78 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.model.dto.ArticleSaveRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.query.ArticlePageQuery; +import com.yunhai.cms.model.vo.ArticleEditVo; +import com.yunhai.cms.service.impl.ArticleService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.Map; + +/** + * 文章管理(方案 §9.2):分页/保存(jsoup 清洗)/发布/下架/置顶/删除。 + */ +@RestController +@RequestMapping("/api/article") +@RequiredArgsConstructor +public class ArticleController { + + private final ArticleService articleService; + + @GetMapping("/page") + public Map page(BoaUser authUserWithDept, ArticlePageQuery query) { + return articleService.page(authUserWithDept, query); + } + + /** 编辑回显(正文/扩展列) */ + @GetMapping("/get") + public ArticleEditVo get(BoaUser authUserWithDept, + @RequestParam("siteId") Integer siteId, + @RequestParam("id") Integer id) { + return articleService.getEdit(authUserWithDept, siteId, id); + } + + /** 保存草稿(content 先过 jsoup 白名单) */ + @PostMapping("/save") + public Integer save(BoaUser authUserWithDept, @Valid @RequestBody ArticleSaveRequest request) { + return articleService.save(authUserWithDept, request); + } + + /** 发布(publish_time 为未来时间即定时发布;作者自动从 CAS 会话填充) */ + @PostMapping("/publish/{id}") + public void publish(BoaUser authUserWithDept, + @PathVariable("id") Integer id, + @RequestParam("siteId") Integer siteId) { + articleService.publish(authUserWithDept, siteId, id); + } + + @PostMapping("/offline/{id}") + public void offline(BoaUser authUserWithDept, + @PathVariable("id") Integer id, + @RequestParam("siteId") Integer siteId) { + articleService.offline(authUserWithDept, siteId, id); + } + + @PostMapping("/topmost/{id}") + public void topmost(BoaUser authUserWithDept, + @PathVariable("id") Integer id, + @RequestParam("siteId") Integer siteId, + @RequestParam("enable") boolean enable) { + articleService.topmost(authUserWithDept, siteId, id, enable); + } + + /** 删除(附件转孤儿,附件管理页清理) */ + @PostMapping("/delete") + public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) { + articleService.delete(authUserWithDept, request); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/AttachmentController.java b/src/main/java/com/yunhai/cms/controller/api/AttachmentController.java new file mode 100644 index 0000000..4a7d94a --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/AttachmentController.java @@ -0,0 +1,56 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.vo.AttachmentVo; +import com.yunhai.cms.service.impl.AttachmentService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import java.util.Map; + +/** + * 附件管理(方案 §9.2):编辑器图片/附件/轮播图/友链 Logo 复用同一上传接口。 + */ +@RestController +@RequestMapping("/api/attachment") +@RequiredArgsConstructor +public class AttachmentController { + + private final AttachmentService attachmentService; + + /** 上传(multipart:file 必填;articleId 可空) */ + @PostMapping("/upload") + public AttachmentVo upload(BoaUser authUserWithDept, + @RequestParam("siteId") Integer siteId, + @RequestParam(value = "articleId", required = false) Integer articleId, + @RequestParam("file") MultipartFile file) { + return attachmentService.upload(authUserWithDept, siteId, articleId, file); + } + + /** 附件列表(orphanOnly=true 仅孤儿) */ + @GetMapping("/page") + public Map page(BoaUser authUserWithDept, + @RequestParam("siteId") Integer siteId, + @RequestParam(value = "orphanOnly", required = false, + defaultValue = "false") boolean orphanOnly, + @RequestParam(value = "page", required = false, + defaultValue = "1") Integer page, + @RequestParam(value = "pageSize", required = false, + defaultValue = "10") Integer pageSize) { + return attachmentService.page(authUserWithDept, siteId, orphanOnly, page, pageSize); + } + + /** 删除附件记录(物理文件运维定期清理) */ + @PostMapping("/delete") + public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) { + attachmentService.delete(authUserWithDept, request); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/AuthController.java b/src/main/java/com/yunhai/cms/controller/api/AuthController.java new file mode 100644 index 0000000..d8123e2 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/AuthController.java @@ -0,0 +1,32 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.config.CmsAuthProperties; +import com.yunhai.cms.model.vo.AuthUserVo; +import com.yunhai.cms.model.vo.SiteVo; +import com.yunhai.cms.service.impl.SiteService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 管理端登录态(CAS 会话用户镜像,方案 §7.2)。 + */ +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final SiteService siteService; + private final CmsAuthProperties authProperties; + + @GetMapping("/user") + public AuthUserVo user(BoaUser authUserWithDept) { + boolean superAdmin = authProperties.isSuperAdmin(authUserWithDept); + List sites = siteService.listFor(authUserWithDept); + return AuthUserVo.of(authUserWithDept, superAdmin, sites); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/BannerController.java b/src/main/java/com/yunhai/cms/controller/api/BannerController.java new file mode 100644 index 0000000..fd5ac54 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/BannerController.java @@ -0,0 +1,46 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.model.dto.BannerSaveRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.vo.BannerVo; +import com.yunhai.cms.service.impl.BannerService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +/** + * 轮播/推荐位管理(方案 §9.2)。 + */ +@RestController +@RequestMapping("/api/banner") +@RequiredArgsConstructor +public class BannerController { + + private final BannerService bannerService; + + @GetMapping("/list") + public List list(BoaUser authUserWithDept, + @RequestParam("siteId") Integer siteId, + @RequestParam(value = "position", required = false) String position) { + return bannerService.list(authUserWithDept, siteId, position); + } + + /** 保存(校验 link_type/link_value 匹配且属本站) */ + @PostMapping("/save") + public BannerVo save(BoaUser authUserWithDept, @Valid @RequestBody BannerSaveRequest request) { + return bannerService.save(authUserWithDept, request); + } + + @PostMapping("/delete") + public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) { + bannerService.delete(authUserWithDept, request); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/BaseApiController.java b/src/main/java/com/yunhai/cms/controller/api/BaseApiController.java new file mode 100644 index 0000000..7f07af6 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/BaseApiController.java @@ -0,0 +1,41 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.service.impl.CmsUserCenterService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 平台代理域接口(Notice BaseApiController 同款契约):GetUserInfo / GetUserMod。 + * 返回裸对象,由平台 ApiResponseAdvice 自动包装为 {code, msg, data}。 + */ +@RestController +@RequestMapping("/api/base") +@RequiredArgsConstructor +public class BaseApiController { + + private final CmsUserCenterService userCenterService; + + /** + * 获取当前登录用户信息(CAS 会话注入;前端 layout 取姓名/头像)。 + */ + @GetMapping("GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + /** + * 获取当前用户已授权的模块列表(左侧菜单树)。 + */ + @GetMapping("GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/CategoryController.java b/src/main/java/com/yunhai/cms/controller/api/CategoryController.java new file mode 100644 index 0000000..2353b94 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/CategoryController.java @@ -0,0 +1,53 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.model.dto.CategorySaveRequest; +import com.yunhai.cms.model.dto.CategorySortRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.vo.CategoryTreeNode; +import com.yunhai.cms.service.impl.CategoryService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import javax.validation.Valid; + +/** + * 栏目管理(方案 §9.2)。 + */ +@RestController +@RequestMapping("/api/category") +@RequiredArgsConstructor +public class CategoryController { + + private final CategoryService categoryService; + + @GetMapping("/tree") + public List tree(BoaUser authUserWithDept, + @RequestParam("siteId") Integer siteId) { + return categoryService.tree(authUserWithDept, siteId); + } + + @PostMapping("/save") + public CategoryTreeNode save(BoaUser authUserWithDept, + @Valid @RequestBody CategorySaveRequest request) { + return categoryService.save(authUserWithDept, request); + } + + @PostMapping("/delete") + public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) { + categoryService.delete(authUserWithDept, request); + } + + /** 子栏目排序(UserBase SortChildren 同语义:按传入顺序落库 sortNo=1..n) */ + @PostMapping("/sort") + public void sort(BoaUser authUserWithDept, @Valid @RequestBody CategorySortRequest request) { + categoryService.sort(authUserWithDept, request); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/LinkController.java b/src/main/java/com/yunhai/cms/controller/api/LinkController.java new file mode 100644 index 0000000..3e7fb36 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/LinkController.java @@ -0,0 +1,43 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.dto.LinkSaveRequest; +import com.yunhai.cms.model.vo.LinkVo; +import com.yunhai.cms.service.impl.LinkService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +/** + * 友情链接管理(方案 §9.2)。 + */ +@RestController +@RequestMapping("/api/link") +@RequiredArgsConstructor +public class LinkController { + + private final LinkService linkService; + + @GetMapping("/list") + public List list(BoaUser authUserWithDept, @RequestParam("siteId") Integer siteId) { + return linkService.list(authUserWithDept, siteId); + } + + @PostMapping("/save") + public LinkVo save(BoaUser authUserWithDept, @Valid @RequestBody LinkSaveRequest request) { + return linkService.save(authUserWithDept, request); + } + + @PostMapping("/delete") + public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) { + linkService.delete(authUserWithDept, request); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/SchoolController.java b/src/main/java/com/yunhai/cms/controller/api/SchoolController.java new file mode 100644 index 0000000..3009fbc --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/SchoolController.java @@ -0,0 +1,35 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.model.vo.SchoolVo; +import com.yunhai.cms.service.impl.SchoolService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 学校工作台(docs/09 §3.3):一级页面=学校列表,二级 /sch/{code}/** 为学校专属管理壳。 + * 权限语义与 /api/site/list 一致;写操作仍走 /api/site/save(添加学校=初始化主站)。 + */ +@RestController +@RequestMapping("/api/school") +@RequiredArgsConstructor +public class SchoolController { + + private final SchoolService schoolService; + + /** 学校工作台列表(超管=全部顶层站点;校级=本校;无角色=空) */ + @GetMapping("/list") + public List list(BoaUser authUserWithDept) { + return schoolService.listFor(authUserWithDept); + } + + /** UserBase 学校选项(添加学校绑定用,仅超管;平台不可达降级空数组) */ + @GetMapping("/options") + public List options(BoaUser authUserWithDept) { + return schoolService.schoolOptions(authUserWithDept); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/SiteController.java b/src/main/java/com/yunhai/cms/controller/api/SiteController.java new file mode 100644 index 0000000..9eda9ea --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/SiteController.java @@ -0,0 +1,39 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.model.dto.SiteSaveRequest; +import com.yunhai.cms.model.vo.SiteVo; +import com.yunhai.cms.service.impl.SiteService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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; + +import javax.validation.Valid; + +/** + * 站点管理(超管,方案 §9.1/§9.2)。 + */ +@RestController +@RequestMapping("/api/site") +@RequiredArgsConstructor +public class SiteController { + + private final SiteService siteService; + + /** 站点列表(超管=全部;校级=本校站点) */ + @GetMapping("/list") + public List list(BoaUser authUserWithDept) { + return siteService.listFor(authUserWithDept); + } + + /** 站点保存(仅超管) */ + @PostMapping("/save") + public SiteVo save(BoaUser authUserWithDept, @Valid @RequestBody SiteSaveRequest request) { + return siteService.save(authUserWithDept, request); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/ThemeController.java b/src/main/java/com/yunhai/cms/controller/api/ThemeController.java new file mode 100644 index 0000000..d6ab2dd --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/ThemeController.java @@ -0,0 +1,83 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.service.impl.ThemeService; +import com.yunhai.cms.theme.ThemeRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import java.time.Duration; +import java.util.List; + +/** + * 主题管理接口(方案 §5.2,路径对齐 /api/{domain} 约定): + * /api/theme/list 主题列表;/api/theme/{name}/asset/** 主题资产代理 + * (screenshot 与预览资产共用,防穿越同 PortalAssetController,name 先过 Registry 白名单)。 + * 走 /api/* CAS 鉴权(现状边界)。 + */ +@RestController +@RequestMapping("/api/theme") +@RequiredArgsConstructor +public class ThemeController { + + private static final Duration ASSET_CACHE = Duration.ofHours(1); + + private final ThemeService themeService; + private final ThemeRegistry themeRegistry; + + @GetMapping("/list") + public List list() { + return themeService.list(); + } + + @GetMapping("/{name}/asset/**") + public ResponseEntity asset(@PathVariable("name") String name, + HttpServletRequest request) { + if (!themeRegistry.contains(name)) { + return ResponseEntity.notFound().build(); + } + String path = extractRelativePath(request, name); + if (path == null || path.isEmpty() || path.contains("..")) { + return ResponseEntity.badRequest().build(); + } + Resource resource = new ClassPathResource("templates/themes/" + name + "/assets/" + path); + if (!resource.exists()) { + return ResponseEntity.notFound().build(); + } + MediaType mediaType = MediaTypeFactory.getMediaType(path) + .orElse(MediaType.APPLICATION_OCTET_STREAM); + return ResponseEntity.ok() + .contentType(mediaType) + .cacheControl(CacheControl.maxAge(ASSET_CACHE).cachePublic()) + .body(resource); + } + + /** + * 从请求 URI 截取 /asset/ 之后的相对路径(同 PortalAssetController: + * 直接解析 URL-decode 后的 URI,兼容两种路径匹配器语义)。 + */ + private String extractRelativePath(HttpServletRequest request, String name) { + String uri = request.getRequestURI(); + String prefix = request.getContextPath() + "/api/theme/" + name + "/asset/"; + int index = uri.indexOf(prefix); + if (index < 0) { + return null; + } + String relative = uri.substring(index + prefix.length()); + try { + relative = java.net.URLDecoder.decode(relative, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return null; + } + return relative.replace('\\', '/'); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/ThemePreviewController.java b/src/main/java/com/yunhai/cms/controller/api/ThemePreviewController.java new file mode 100644 index 0000000..436a090 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/ThemePreviewController.java @@ -0,0 +1,71 @@ +package com.yunhai.cms.controller.api; + +import com.yunhai.cms.model.portal.PortalSiteSnapshot; +import com.yunhai.cms.service.impl.PortalQueryService; +import com.yunhai.cms.service.impl.SiteService; +import com.yunhai.cms.service.impl.ThemeService; +import com.yunhai.cms.theme.ThemeInfo; +import com.yunhai.cms.theme.ThemeVarsCss; +import com.yunhai.cms.controller.portal.PortalPageRenderer; +import com.yunhai.cms.utils.PortalUrlHelper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Map; + +/** + * 主题预览(方案 §5.5):管理端鉴权的代理渲染接口,iframe 同源加载。 + * 预览渲染用站点快照的浅拷贝覆盖 theme/themeParams,资产 URL 切换到管理端代理 + * (防预览资产串包,R1);不在匿名门户 URL 开任何覆盖参数。 + * B-6:page 白名单(P1 仅 home,非法值明确报错);内容源站点过可管理校验(requireManageable)。 + */ +@Controller +@RequestMapping("/api/theme") +@RequiredArgsConstructor +public class ThemePreviewController { + + private final ThemeService themeService; + private final PortalQueryService portalQueryService; + private final PortalPageRenderer pageRenderer; + private final SiteService siteService; + + /** 预览首页(page 参数 P1 仅支持 home):no-store 防止进缓存/CDN(方案 §10 风险表) */ + @GetMapping("/preview") + public String preview(@RequestParam("siteCode") String siteCode, + @RequestParam("theme") String theme, + @RequestParam(value = "page", required = false, defaultValue = "home") String page, + BoaUser authUserWithDept, + HttpServletRequest request, HttpServletResponse response, Model model) { + response.setHeader("Cache-Control", "no-store"); + // B-6①:page 白名单——非法值明确报错,不静默当 home 渲染(接口语义误导) + if (!"home".equals(page)) { + throw new BizException(ErrorCode.PARAM_ERROR, "不支持的预览页面:" + page + "(当前仅支持 home)"); + } + ThemeInfo info = themeService.requireRegistered(theme); + PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode); + if (site == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "内容源站点不存在或已停用:" + siteCode); + } + // B-6②:预览渲染的是内容源站点数据快照,与管理端同权限级(requireManageable) + if (authUserWithDept == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未登录或会话失效,无法预览"); + } + siteService.requireManageable(authUserWithDept, site.getId()); + PortalSiteSnapshot previewSite = site.shallowCopy(); + previewSite.setTheme(theme); + Map params = themeService.extractPreviewParams(info, request.getParameterMap()); + previewSite.setThemeParams(params); + previewSite.setThemeVarsCss(ThemeVarsCss.build(info, params)); + return pageRenderer.renderHome(previewSite, PortalUrlHelper.forPreview(request, siteCode, theme), model); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java b/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java new file mode 100644 index 0000000..f99525a --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java @@ -0,0 +1,87 @@ +package com.yunhai.cms.controller.portal; + +import com.yunhai.cms.service.impl.PortalQueryService; +import com.yunhai.cms.model.portal.PortalSiteSnapshot; +import com.yunhai.cms.theme.ThemeRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +import javax.servlet.http.HttpServletRequest; +import java.time.Duration; + +/** + * 门户主题资产(方案 §8.2/§8.6):/portal/{siteCode}/assets/{theme}/** 解析到 + * classpath:/templates/themes/{theme}/assets/**。URL 由 PortalUrlHelper 以 + * public_base_url 为基准生成并携带主题段(B-3),学校域名形态同样命中本控制器。 + * + *

主题段取自 URL 而非站点当前皮肤:换肤后 URL 即变,浏览器/CDN 对旧 URL 的 + * max-age 缓存天然失效;旧 URL 本身仍可服务(URL 与内容一一对应,缓存语义安全)。 + */ +@Slf4j +@Controller +@RequiredArgsConstructor +public class PortalAssetController { + + private static final Duration ASSET_CACHE = Duration.ofHours(1); + + private final PortalQueryService portalQueryService; + private final ThemeRegistry themeRegistry; + + @GetMapping("/portal/{siteCode}/assets/{theme}/**") + public ResponseEntity asset(@PathVariable("siteCode") String siteCode, + @PathVariable("theme") String theme, + HttpServletRequest request) { + // 主题段必须已注册(兼防路径注入:未注册名一律 404) + if (!themeRegistry.contains(theme)) { + return ResponseEntity.notFound().build(); + } + PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode); + if (site == null) { + return ResponseEntity.notFound().build(); + } + String path = extractRelativePath(request, siteCode, theme); + if (path == null || path.isEmpty() || path.contains("..")) { + return ResponseEntity.badRequest().build(); + } + Resource resource = new ClassPathResource( + "templates/themes/" + theme + "/assets/" + path); + if (!resource.exists()) { + return ResponseEntity.notFound().build(); + } + MediaType mediaType = MediaTypeFactory.getMediaType(path) + .orElse(MediaType.APPLICATION_OCTET_STREAM); + return ResponseEntity.ok() + .contentType(mediaType) + .cacheControl(CacheControl.maxAge(ASSET_CACHE).cachePublic()) + .body(resource); + } + + /** + * 从请求 URI 截取 /assets/{theme}/ 之后的相对路径。 + * 直接解析 URI(URL-decode 后),兼容 AntPathMatcher 与 PathPatternParser 两种匹配器语义。 + */ + private String extractRelativePath(HttpServletRequest request, String siteCode, String theme) { + String uri = request.getRequestURI(); + String prefix = request.getContextPath() + "/portal/" + siteCode + "/assets/" + theme + "/"; + int index = uri.indexOf(prefix); + if (index < 0) { + return null; + } + String relative = uri.substring(index + prefix.length()); + try { + relative = java.net.URLDecoder.decode(relative, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return null; + } + return relative.replace('\\', '/'); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalController.java b/src/main/java/com/yunhai/cms/controller/portal/PortalController.java new file mode 100644 index 0000000..7765a5f --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalController.java @@ -0,0 +1,295 @@ +package com.yunhai.cms.controller.portal; + +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.enums.LinkTypeEnum; +import com.yunhai.cms.model.portal.PortalBanner; +import com.yunhai.cms.model.portal.PortalCategory; +import com.yunhai.cms.model.portal.PortalListData; +import com.yunhai.cms.model.portal.PortalSiteSnapshot; +import com.yunhai.cms.repository.CmsArticleRepository; +import com.yunhai.cms.service.impl.PortalQueryService; +import com.yunhai.cms.service.impl.ViewCounterService; +import com.yunhai.cms.utils.PortalUrlHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 门户 SSR(方案 §8,匿名可访问——CAS url-pattern 只配 /api/*,勿改)。 + * 站点身份来自路径不依赖 Host(方案 §8.6);所有链接经 PortalUrlHelper 按 + * public_base_url 生成,base 域子路径与学校独立域名两种暴露形态共用渲染。 + */ +@Slf4j +@Controller +@RequiredArgsConstructor +public class PortalController { + + /** 首页每个一级栏目取 N 条 */ + private static final int HOME_BLOCK_SIZE = 8; + /** 首页置顶区取 N 条 */ + private static final int HOME_TOP_SIZE = 6; + + private final PortalQueryService portalQueryService; + private final CmsArticleRepository articleRepository; + private final ViewCounterService viewCounterService; + private final PortalPageRenderer pageRenderer; + + @Value("${cms.portal.detail-max-age:300}") + private int detailMaxAge; + + /** base 域对外基地址(C-6:public_base_url 未配置时的规范派生基准;空=按请求派生) */ + @Value("${cms.portal.base-origin:}") + private String baseOrigin; + + /** 首页:站点配置 + home 轮播位 + 各一级栏目 top N + 置顶文章 + 友链分组(装配在 PortalPageRenderer,方案 §5.5) */ + @GetMapping("/portal/{siteCode}/") + public String home(@PathVariable("siteCode") String siteCode, HttpServletRequest request, + HttpServletResponse response, Model model) throws Exception { + PortalSiteSnapshot site = requireSite(siteCode, response); + if (site == null) { + return null; + } + return pageRenderer.renderHome(site, buildUrl(site, request), model); + } + + /** 栏目页(列表/单页);外链栏目 302(方案 §8.1) */ + @GetMapping("/portal/{siteCode}/c/{categoryId}") + public String category(@PathVariable("siteCode") String siteCode, + @PathVariable("categoryId") Integer categoryId, + @RequestParam(value = "page", required = false, defaultValue = "1") Integer page, + HttpServletRequest request, HttpServletResponse response, + Model model) throws Exception { + PortalSiteSnapshot site = requireSite(siteCode, response); + if (site == null) { + return null; + } + PortalCategory category = findCategory(site, categoryId); + if (category == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return null; + } + if (category.getType() != null && category.getType() == CategoryTypeEnum.LINK.getCode()) { + PortalUrlHelper fallback = buildUrl(site, request); + response.sendRedirect(category.getLinkUrl() == null ? fallback.home() : category.getLinkUrl()); + return null; + } + PortalUrlHelper url = buildUrl(site, request); + model.addAttribute("url", url); + model.addAttribute("site", site); + model.addAttribute("themeParams", site.getThemeParams()); + model.addAttribute("themeVarsCss", site.getThemeVarsCss()); + model.addAttribute("themeBase", "themes/" + site.getTheme()); + model.addAttribute("navCategories", site.getCategories()); + model.addAttribute("currentCategoryId", categoryId); + model.addAttribute("category", category); + model.addAttribute("pageTitle", seoTitle(category, site)); + model.addAttribute("seoDescription", category.getSeoDescription()); + model.addAttribute("canonical", url.category(categoryId)); + + if (category.getType() != null && category.getType() == CategoryTypeEnum.SINGLE.getCode()) { + // 单页正文上传地址渲染期补基准(B-1):拷贝后重写,不污染快照缓存体 + model.addAttribute("category", category.withContent( + url.rewriteUploadUrls(category.getContent()))); + return "themes/" + site.getTheme() + "/single"; + } + PortalListData listData = portalQueryService.categoryPage(categoryId, page, 10); + if (listData == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return null; + } + model.addAttribute("listData", listData); + model.addAttribute("searchMode", false); + return "themes/" + site.getTheme() + "/list"; + } + + /** 文章详情:正文 th:utext + 上下篇 + 浏览量异步累加 + Cache-Control(方案 §8.3) */ + @GetMapping("/portal/{siteCode}/a/{articleId}") + public String article(@PathVariable("siteCode") String siteCode, + @PathVariable("articleId") Integer articleId, + HttpServletRequest request, HttpServletResponse response, + Model model) throws Exception { + PortalSiteSnapshot site = requireSite(siteCode, response); + if (site == null) { + return null; + } + com.yunhai.cms.model.portal.PortalArticleDetail detail = + portalQueryService.articleDetail(articleId); + if (detail == null || !detail.getSiteId().equals(site.getId())) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return null; + } + viewCounterService.increment(articleId); + + PortalUrlHelper url = buildUrl(site, request); + model.addAttribute("url", url); + model.addAttribute("site", site); + model.addAttribute("themeParams", site.getThemeParams()); + model.addAttribute("themeVarsCss", site.getThemeVarsCss()); + model.addAttribute("themeBase", "themes/" + site.getTheme()); + model.addAttribute("navCategories", site.getCategories()); + model.addAttribute("currentCategoryId", detail.getCategoryId()); + // 面包屑栏目名(视觉精修 D2-2):按栏目树查名,查不到(栏目已删)退"正文" + PortalCategory detailCategory = findCategory(site, detail.getCategoryId()); + model.addAttribute("currentCategoryName", detailCategory == null ? "正文" : detailCategory.getName()); + // 正文上传地址渲染期补基准(B-1):详情来自 cms:article 缓存(快照跨两种暴露形态共享), + // 拷贝后重写,避免把某一形态的绝对 URL 写回缓存 + model.addAttribute("article", detail.withContent( + url.rewriteUploadUrls(detail.getContent()))); + // og:image(C-7):文章封面(无封面则不输出该标签) + model.addAttribute("ogImage", url.upload(detail.getCoverImage())); + model.addAttribute("pageTitle", detail.getTitle() + " - " + site.getName()); + model.addAttribute("seoDescription", detail.getSummary()); + model.addAttribute("canonical", url.article(articleId)); + model.addAttribute("prevArticle", prevArticle(site.getId(), articleId, detail.getPublishTime())); + model.addAttribute("nextArticle", nextArticle(site.getId(), articleId, detail.getPublishTime())); + response.setHeader("Cache-Control", "max-age=" + detailMaxAge); + return "themes/" + site.getTheme() + "/detail"; + } + + /** 站内搜索:title/summary LIKE(实时查询不缓存) */ + @GetMapping("/portal/{siteCode}/search") + public String search(@PathVariable("siteCode") String siteCode, + @RequestParam(value = "q", required = false, defaultValue = "") String q, + @RequestParam(value = "page", required = false, defaultValue = "1") Integer page, + HttpServletRequest request, HttpServletResponse response, + Model model) throws Exception { + PortalSiteSnapshot site = requireSite(siteCode, response); + if (site == null) { + return null; + } + PortalUrlHelper url = buildUrl(site, request); + model.addAttribute("url", url); + model.addAttribute("site", site); + model.addAttribute("themeParams", site.getThemeParams()); + model.addAttribute("themeVarsCss", site.getThemeVarsCss()); + model.addAttribute("themeBase", "themes/" + site.getTheme()); + model.addAttribute("navCategories", site.getCategories()); + model.addAttribute("q", q); + model.addAttribute("pageTitle", "搜索:" + q + " - " + site.getName()); + model.addAttribute("seoDescription", null); + model.addAttribute("canonical", url.search(q, page)); + model.addAttribute("searchMode", true); + if (q.trim().isEmpty()) { + model.addAttribute("listData", emptyList(page)); + return "themes/" + site.getTheme() + "/list"; + } + org.springframework.data.domain.Page result = + portalQueryService.search(site.getId(), q, page, 10); + PortalListData listData = new PortalListData(); + listData.setSiteId(site.getId()); + listData.setItems(result.getContent()); + listData.setTotal(result.getTotalElements()); + listData.setPage(page); + listData.setPageSize(10); + listData.setTotalPages(result.getTotalPages()); + model.addAttribute("listData", listData); + return "themes/" + site.getTheme() + "/list"; + } + + // ===== 内部装配 ===== + + private PortalSiteSnapshot requireSite(String siteCode, HttpServletResponse response) throws Exception { + PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode); + if (site == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return null; + } + return site; + } + + /** + * URL 生成器基准三级决策(方案 §8.6): + * ① 站点 public_base_url(绑学校域名); + * ② 平台 cms.portal.base-origin(未绑域时的 base 域规范形态,C-6); + * ③ 当前请求派生(本地/预览形态,反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。 + * theme 随 helper 走,使资产 URL 含主题段(B-3)。 + */ + private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) { + if (site.getPublicBaseUrl() != null) { + return new PortalUrlHelper(site.getPublicBaseUrl(), site.getCode(), site.getTheme(), baseOrigin); + } + if (baseOrigin != null && !baseOrigin.trim().isEmpty()) { + return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getCode(), site.getTheme()); + } + return PortalUrlHelper.fromRequest(request, site.getCode(), site.getTheme()); + } + + /** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */ + private PortalCategory findCategory(PortalSiteSnapshot site, Integer categoryId) { + for (PortalCategory root : site.getCategories()) { + PortalCategory hit = findCategoryDeep(root, categoryId); + if (hit != null) { + return hit; + } + } + return null; + } + + private PortalCategory findCategoryDeep(PortalCategory node, Integer categoryId) { + if (node.getId().equals(categoryId)) { + return node; + } + for (PortalCategory child : node.getChildren()) { + PortalCategory hit = findCategoryDeep(child, categoryId); + if (hit != null) { + return hit; + } + } + return null; + } + + + private PortalListData.Item toItem(CmsArticle a) { + PortalListData.Item item = new PortalListData.Item(); + item.setId(a.getId()); + item.setTitle(a.getTitle()); + item.setSummary(a.getSummary()); + item.setCoverImage(a.getCoverImage()); + item.setPublishTime(a.getPublishTime()); + item.setTopmost(a.getTopmost()); + return item; + } + + private PortalListData.Item prevArticle(Integer siteId, Integer articleId, LocalDateTime publishTime) { + LocalDateTime pt = publishTime == null ? LocalDateTime.now() : publishTime; + List list = articleRepository.findPrevPublished(siteId, + ArticleStatusEnum.PUBLISHED.getCode(), pt, articleId, PageRequest.of(0, 1)); + return list.isEmpty() ? null : toItem(list.get(0)); + } + + private PortalListData.Item nextArticle(Integer siteId, Integer articleId, LocalDateTime publishTime) { + LocalDateTime pt = publishTime == null ? LocalDateTime.MIN : publishTime; + List list = articleRepository.findNextPublished(siteId, + ArticleStatusEnum.PUBLISHED.getCode(), pt, articleId, PageRequest.of(0, 1)); + return list.isEmpty() ? null : toItem(list.get(0)); + } + + private PortalListData emptyList(Integer page) { + PortalListData data = new PortalListData(); + data.setPage(page == null ? 1 : page); + return data; + } + + private String seoTitle(PortalCategory category, PortalSiteSnapshot site) { + String title = category.getSeoTitle() == null || category.getSeoTitle().isEmpty() + ? category.getName() : category.getSeoTitle(); + return title + " - " + site.getName(); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalIndexController.java b/src/main/java/com/yunhai/cms/controller/portal/PortalIndexController.java new file mode 100644 index 0000000..a6e3b1c --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalIndexController.java @@ -0,0 +1,37 @@ +package com.yunhai.cms.controller.portal; + +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.repository.CmsSiteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 门户根路径(/cms/portal/):跳转首个启用的顶层校站点(院系子站不作默认入口,《院系子站方案》)。 + * 容器健康探活打此路径(docker-compose healthcheck),返回 302 即视为存活。 + */ +@RestController +@RequiredArgsConstructor +public class PortalIndexController { + + private final CmsSiteRepository siteRepository; + + @GetMapping({"/portal", "/portal/"}) + public void index(HttpServletRequest request, HttpServletResponse response) throws Exception { + // B-5:显式按 id 升序,多顶层站时 302 目标确定(healthcheck 与用户首跳不漂移) + List sites = siteRepository.findByStatusOrderByIdAsc(1); + if (sites.isEmpty()) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "暂无启用站点"); + return; + } + CmsSite target = sites.stream() + .filter(s -> s.getParentSiteId() == null || s.getParentSiteId() == 0) + .findFirst() + .orElse(sites.get(0)); + response.sendRedirect(request.getContextPath() + "/portal/" + target.getCode() + "/"); + } +} diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java b/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java new file mode 100644 index 0000000..d3fbea8 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java @@ -0,0 +1,188 @@ +package com.yunhai.cms.controller.portal; + +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.enums.LinkTypeEnum; +import com.yunhai.cms.model.portal.PortalBanner; +import com.yunhai.cms.model.portal.PortalCategory; +import com.yunhai.cms.model.portal.PortalListData; +import com.yunhai.cms.model.portal.PortalSiteSnapshot; +import com.yunhai.cms.repository.CmsArticleRepository; +import com.yunhai.cms.utils.PortalUrlHelper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.ui.Model; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用, + * 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。 + */ +@Component +@RequiredArgsConstructor +public class PortalPageRenderer { + + /** 首页每个一级栏目取 N 条 */ + private static final int HOME_BLOCK_SIZE = 8; + /** 首页置顶区取 N 条 */ + private static final int HOME_TOP_SIZE = 6; + + private final CmsArticleRepository articleRepository; + + /** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载) */ + public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) { + model.addAttribute("url", url); + model.addAttribute("site", site); + model.addAttribute("themeParams", site.getThemeParams()); + model.addAttribute("themeVarsCss", site.getThemeVarsCss()); + model.addAttribute("themeBase", "themes/" + site.getTheme()); + model.addAttribute("navCategories", site.getCategories()); + List> banners = activeBanners(site, "home", url); + model.addAttribute("banners", banners); + model.addAttribute("topArticles", topArticles(site.getId())); + model.addAttribute("focusArticles", focusArticles(site.getId())); + model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url)); + model.addAttribute("pageTitle", site.getName()); + model.addAttribute("seoDescription", site.getConfig() == null ? null + : site.getConfig().get("seo_description")); + model.addAttribute("canonical", url.home()); + // og:image(C-7):轮播首图(已补基准)优先,退站点 Logo + Object firstBanner = banners.isEmpty() ? null : banners.get(0).get("imageUrl"); + Object logo = site.getConfig() == null ? null : site.getConfig().get("logo"); + model.addAttribute("ogImage", firstBanner != null ? firstBanner : url.upload( + logo == null ? null : logo.toString())); + return "themes/" + site.getTheme() + "/home"; + } + + // ===== 内部装配(自 PortalController 原样迁移)===== + + /** 启用中且在时间窗内的指定位轮播,并解析为完整跳转 URL */ + private List> activeBanners(PortalSiteSnapshot site, String position, + PortalUrlHelper url) { + List> result = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + for (PortalBanner b : site.getBanners()) { + if (!position.equals(b.getPosition())) { + continue; + } + if (b.getStartTime() != null && now.isBefore(b.getStartTime())) { + continue; // 定时上线未到 + } + if (b.getEndTime() != null && !now.isBefore(b.getEndTime())) { + continue; // 已过下线时间 + } + Map item = new HashMap<>(); + item.put("id", b.getId()); + item.put("title", b.getTitle()); + // 上传图渲染期补基准(B-1):入库相对地址 → 当前基准完整 URL + item.put("imageUrl", url.upload(b.getImageUrl())); + item.put("linkUrl", resolveBannerLink(b, url)); + result.add(item); + } + return result; + } + + private String resolveBannerLink(PortalBanner b, PortalUrlHelper url) { + LinkTypeEnum type = LinkTypeEnum.byCode(b.getLinkType()); + if (type == LinkTypeEnum.ARTICLE) { + return url.article(b.getLinkValue()); + } + if (type == LinkTypeEnum.CATEGORY) { + return url.category(b.getLinkValue()); + } + return b.getLinkValue(); + } + + /** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */ + private List topArticles(Integer siteId) { + List articles = articleRepository.findBySiteIdAndStatus( + siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(), + PageRequest.of(0, HOME_TOP_SIZE, + Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime")))); + List items = new ArrayList<>(); + for (com.yunhai.cms.entity.CmsArticle a : articles) { + items.add(toItem(a)); + } + return items; + } + + /** + * 首页"图片新闻"卡(视觉精修 D2-1):有封面的已发布文章取 3 条(发布时间倒序)。 + * 无封面文章不进此卡(无封面边界由列表页文字行呈现,不造占位图)。 + */ + private List focusArticles(Integer siteId) { + List articles = articleRepository.findBySiteIdAndStatus( + siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(), + PageRequest.of(0, 30, Sort.by(Sort.Order.desc("publishTime")))); + List items = new ArrayList<>(); + for (com.yunhai.cms.entity.CmsArticle a : articles) { + if (a.getCoverImage() == null || a.getCoverImage().trim().isEmpty()) { + continue; + } + items.add(toItem(a)); + if (items.size() >= 3) { + break; + } + } + return items; + } + + /** + * 首页一级栏目区块:每栏目精确取 top N(列表型栏目才出区块)。 + * D1-2:区块聚合列表型子孙栏目文章(文章通常挂叶子栏目,父栏目区块必须显示汇总)。 + */ + private List> categoryBlocks(Integer siteId, List categories, + PortalUrlHelper url) { + List> blocks = new ArrayList<>(); + for (PortalCategory category : categories) { + if (category.getType() == null || category.getType() != CategoryTypeEnum.LIST.getCode()) { + continue; + } + List categoryIds = new ArrayList<>(); + collectListDescendantIds(category, categoryIds); + List articles = articleRepository + .findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds, + com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(), + PageRequest.of(0, HOME_BLOCK_SIZE, + Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime")))) + .getContent(); + List items = new ArrayList<>(); + for (com.yunhai.cms.entity.CmsArticle a : articles) { + items.add(toItem(a)); + } + Map block = new HashMap<>(); + block.put("category", category); + block.put("items", items); + block.put("moreUrl", url.category(category.getId())); + blocks.add(block); + } + return blocks; + } + + /** 快照栏目树深度优先收集列表型子孙 id(含自身;单页/外链子孙不入列也不下钻) */ + private void collectListDescendantIds(PortalCategory category, List ids) { + ids.add(category.getId()); + for (PortalCategory child : category.getChildren()) { + if (child.getType() != null && child.getType() == CategoryTypeEnum.LIST.getCode()) { + collectListDescendantIds(child, ids); + } + } + } + + private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) { + PortalListData.Item item = new PortalListData.Item(); + item.setId(a.getId()); + item.setTitle(a.getTitle()); + item.setSummary(a.getSummary()); + item.setCoverImage(a.getCoverImage()); + item.setPublishTime(a.getPublishTime()); + item.setTopmost(a.getTopmost()); + return item; + } +} diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalWebExceptionHandler.java b/src/main/java/com/yunhai/cms/controller/portal/PortalWebExceptionHandler.java new file mode 100644 index 0000000..136a105 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalWebExceptionHandler.java @@ -0,0 +1,47 @@ +package com.yunhai.cms.controller.portal; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import javax.servlet.http.HttpServletResponse; + +/** + * 门户 HTML 路由异常落地为错误页(体检 H-1/H-2 根因修复)。 + * + *

门户控制器抛异常原本落到平台 JSON 兜底(匿名访客看到裸 {"code":6,...})。 + * 本 advice 将参数类异常转 sendError(400)、其余转 sendError(500), + * 经 Boot 错误页机制渲染 templates/error/ 下的皮肤化错误页; + * 响应已提交(模板渲染中途失败)时只记日志不再改写响应。 + */ +@Slf4j +@ControllerAdvice(assignableTypes = {PortalController.class, PortalIndexController.class, + PortalAssetController.class}) +@Order(Ordered.HIGHEST_PRECEDENCE) +public class PortalWebExceptionHandler { + + @ExceptionHandler({MethodArgumentTypeMismatchException.class, + MissingServletRequestParameterException.class}) + public void badRequest(Exception e, HttpServletResponse response) throws Exception { + if (response.isCommitted()) { + log.warn("门户参数异常但响应已提交: {}", e.getMessage()); + return; + } + log.warn("门户参数异常: {}", e.getMessage()); + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public void serverError(Exception e, HttpServletResponse response) throws Exception { + if (response.isCommitted()) { + log.warn("门户渲染异常且响应已提交: {}", e.getMessage()); + return; + } + log.error("门户渲染异常", e); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/com/yunhai/cms/entity/BaseEntity.java b/src/main/java/com/yunhai/cms/entity/BaseEntity.java new file mode 100644 index 0000000..235e07b --- /dev/null +++ b/src/main/java/com/yunhai/cms/entity/BaseEntity.java @@ -0,0 +1,43 @@ +package com.yunhai.cms.entity; + +import com.yunhai.cms.common.CmsAuditListener; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.EntityListeners; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.MappedSuperclass; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 CmsAuditListener 自动填充)。 + * 操作日志独立表为演进项(方案 §2),MVP 靠本审计四字段追溯。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(CmsAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "create_by", updatable = false) + private String createBy; + + @Column(name = "create_time", updatable = false) + private LocalDateTime createTime; + + @Column(name = "update_by") + private String updateBy; + + @Column(name = "update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/cms/entity/CmsArticle.java b/src/main/java/com/yunhai/cms/entity/CmsArticle.java new file mode 100644 index 0000000..90959c1 --- /dev/null +++ b/src/main/java/com/yunhai/cms/entity/CmsArticle.java @@ -0,0 +1,84 @@ +package com.yunhai.cms.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * CMS 文章(方案 §5.1 表3)。 + * 审核状态机为演进项(触发后加 audit_status 列);ext_json 保留键: + * {@code wechat}(推送回执,本期不写)、{@code albums}(图集演进)。 + */ +@Getter +@Setter +@Entity +@Table(name = "cms_article", indexes = { + @Index(name = "idx_art_list", columnList = "site_id, category_id, status, publish_time"), + @Index(name = "idx_art_site_top", columnList = "site_id, status, topmost")}) +public class CmsArticle extends BaseEntity { + + /** 所属站点(隔离/查询维度;文章不冗余 sch_id,权限经 site 一跳映射) */ + @Column(name = "site_id", nullable = false) + private Integer siteId; + + /** 所属栏目(逻辑外键 → cms_category.id) */ + @Column(name = "category_id", nullable = false) + private Integer categoryId; + + /** 标题 */ + @Column(name = "title", nullable = false, length = 200) + private String title; + + /** 摘要(列表/SEO 用,空=截取正文) */ + @Column(name = "summary", length = 500) + private String summary; + + /** 正文 HTML(wangEditor 产出,保存时 jsoup 白名单清洗,方案 §8.5) */ + @Column(name = "content", columnDefinition = "LONGTEXT") + private String content; + + /** 封面图 URL */ + @Column(name = "cover_image", length = 500) + private String coverImage; + + /** 作者账号(发布时从 BoaUser 填充) */ + @Column(name = "author_account", length = 64) + private String authorAccount; + + /** 作者姓名(冗余展示) */ + @Column(name = "author_name", length = 64) + private String authorName; + + /** 来源(转载出处) */ + @Column(name = "source", length = 128) + private String source; + + /** 状态:0=草稿 1=已发布 2=已下架(见 ArticleStatusEnum) */ + @Column(name = "status", nullable = false) + private Integer status = 0; + + /** 置顶:0/1 */ + @Column(name = "topmost", nullable = false) + private Integer topmost = 0; + + /** 发布时间(空=保存即发布;未来=定时发布) */ + @Column(name = "publish_time") + private LocalDateTime publishTime; + + /** 下架时间(可选,@Scheduled 每分钟扫描到期记录) */ + @Column(name = "offline_time") + private LocalDateTime offlineTime; + + /** 浏览量(MVP 异步累加,量大后改缓存聚合) */ + @Column(name = "view_count", nullable = false) + private Integer viewCount = 0; + + /** 扩展字段 JSON(wechat/albums 预留,免 DDL 演进) */ + @Column(name = "ext_json", columnDefinition = "JSON") + private String extJson; +} diff --git a/src/main/java/com/yunhai/cms/entity/CmsAttachment.java b/src/main/java/com/yunhai/cms/entity/CmsAttachment.java new file mode 100644 index 0000000..41e6d1a --- /dev/null +++ b/src/main/java/com/yunhai/cms/entity/CmsAttachment.java @@ -0,0 +1,50 @@ +package com.yunhai.cms.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * CMS 附件(正文图片 + 文档附件 + 轮播图/友链 Logo 复用同一上传链路,方案 §7.3)。 + * 文章删除时附件记录保留、article_id 置空成"孤儿",由附件管理页清理。 + */ +@Getter +@Setter +@Entity +@Table(name = "cms_attachment", indexes = { + @Index(name = "idx_att_article", columnList = "article_id"), + @Index(name = "idx_att_site", columnList = "site_id")}) +public class CmsAttachment extends BaseEntity { + + /** 所属站点 */ + @Column(name = "site_id", nullable = false) + private Integer siteId; + + /** 关联文章(编辑器图片保存时回填) */ + @Column(name = "article_id") + private Integer articleId; + + /** 原始文件名 */ + @Column(name = "file_name", nullable = false, length = 255) + private String fileName; + + /** 存储路径(相对 FILE_PATH) */ + @Column(name = "file_path", nullable = false, length = 500) + private String filePath; + + /** Web 访问 URL */ + @Column(name = "web_url", nullable = false, length = 500) + private String webUrl; + + /** 字节数 */ + @Column(name = "file_size") + private Long fileSize; + + /** MIME 类型 */ + @Column(name = "mime_type", length = 128) + private String mimeType; +} diff --git a/src/main/java/com/yunhai/cms/entity/CmsBanner.java b/src/main/java/com/yunhai/cms/entity/CmsBanner.java new file mode 100644 index 0000000..43300d8 --- /dev/null +++ b/src/main/java/com/yunhai/cms/entity/CmsBanner.java @@ -0,0 +1,62 @@ +package com.yunhai.cms.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * CMS 轮播/推荐位(首页 banner、图片新闻位等,方案 §5.1 表5)。 + * 定时上下线靠门户渲染时过滤 start/end(无需调度任务改状态)。 + * 广告投放统计不做(方案 §2)。 + */ +@Getter +@Setter +@Entity +@Table(name = "cms_banner", indexes = @Index(name = "idx_banner", columnList = "site_id, position, status, sort_no")) +public class CmsBanner extends BaseEntity { + + /** 所属站点 */ + @Column(name = "site_id", nullable = false) + private Integer siteId; + + /** 展示位标识(home=首页轮播;皮肤包约定其他位) */ + @Column(name = "position", nullable = false, length = 32) + private String position = "home"; + + /** 标题(alt 文案/无图兜底) */ + @Column(name = "title", nullable = false, length = 200) + private String title; + + /** 图片 URL(上传至 FILE_PATH) */ + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + /** 跳转类型:1=文章 2=栏目 3=外链(见 LinkTypeEnum) */ + @Column(name = "link_type", nullable = false) + private Integer linkType = 1; + + /** 跳转目标(文章ID/栏目ID/URL,按 link_type 解释) */ + @Column(name = "link_value", length = 500) + private String linkValue; + + /** 同级排序(升序) */ + @Column(name = "sort_no", nullable = false) + private Integer sortNo = 0; + + /** 上线时间(空=立即;未来=定时上线) */ + @Column(name = "start_time") + private LocalDateTime startTime; + + /** 下线时间(可选,门户渲染时过滤) */ + @Column(name = "end_time") + private LocalDateTime endTime; + + /** 状态:0=停用 1=启用 */ + @Column(name = "status", nullable = false) + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/entity/CmsCategory.java b/src/main/java/com/yunhai/cms/entity/CmsCategory.java new file mode 100644 index 0000000..3eaa83c --- /dev/null +++ b/src/main/java/com/yunhai/cms/entity/CmsCategory.java @@ -0,0 +1,67 @@ +package com.yunhai.cms.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * CMS 栏目(站点内树形,方案 §5.1 表2)。 + * 旧 CMS3.0 的"频道"层收敛为本表 type(列表/单页/外链),覆盖原频道全部用法。 + */ +@Getter +@Setter +@Entity +@Table(name = "cms_category", indexes = @Index(name = "idx_cat_tree", columnList = "site_id, parent_id, sort_no")) +public class CmsCategory extends BaseEntity { + + /** 所属站点(逻辑外键 → cms_site.id) */ + @Column(name = "site_id", nullable = false) + private Integer siteId; + + /** 父栏目ID(0=根) */ + @Column(name = "parent_id", nullable = false) + private Integer parentId = 0; + + /** 栏目名称 */ + @Column(name = "name", nullable = false, length = 64) + private String name; + + /** 栏目类型:1=文章列表 2=单页 3=外链(见 CategoryTypeEnum) */ + @Column(name = "type", nullable = false) + private Integer type = 1; + + /** 外链地址(type=3 必填) */ + @Column(name = "link_url", length = 500) + private String linkUrl; + + /** 栏目模板覆盖(空=按 type 取默认,预留能力,方案 §8.2) */ + @Column(name = "template", length = 64) + private String template; + + /** + * 单页栏目内容(type=2 时门户直接渲染;经 jsoup 白名单清洗入库)。 + * 注:方案 §8.1 要求单页直接渲染 content,但 §5.1 DDL 漏列,此处补齐(ddl-auto=update 自动加列)。 + */ + @Column(name = "content", columnDefinition = "LONGTEXT") + private String content; + + /** 同级排序(升序) */ + @Column(name = "sort_no", nullable = false) + private Integer sortNo = 0; + + /** SEO 标题(空=用栏目名) */ + @Column(name = "seo_title", length = 200) + private String seoTitle; + + /** SEO 描述 */ + @Column(name = "seo_description", length = 500) + private String seoDescription; + + /** 状态:0=隐藏 1=显示 */ + @Column(name = "status", nullable = false) + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/entity/CmsLink.java b/src/main/java/com/yunhai/cms/entity/CmsLink.java new file mode 100644 index 0000000..645d88c --- /dev/null +++ b/src/main/java/com/yunhai/cms/entity/CmsLink.java @@ -0,0 +1,48 @@ +package com.yunhai.cms.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * CMS 友情链接(页脚/首页分组成员,方案 §5.1 表6)。 + * 分组即旧 linkCategory:上级部门/兄弟院校/合作单位…,group_name 字符串直接分组。 + */ +@Getter +@Setter +@Entity +@Table(name = "cms_link", indexes = @Index(name = "idx_link", columnList = "site_id, group_name, sort_no")) +public class CmsLink extends BaseEntity { + + /** 所属站点 */ + @Column(name = "site_id", nullable = false) + private Integer siteId; + + /** 分组(上级部门/兄弟院校/合作单位…) */ + @Column(name = "group_name", nullable = false, length = 64) + private String groupName = "default"; + + /** 链接名称 */ + @Column(name = "name", nullable = false, length = 128) + private String name; + + /** Logo URL(可选,文字链可空) */ + @Column(name = "logo_url", length = 500) + private String logoUrl; + + /** 链接地址 */ + @Column(name = "link_url", nullable = false, length = 500) + private String linkUrl; + + /** 组内排序(升序) */ + @Column(name = "sort_no", nullable = false) + private Integer sortNo = 0; + + /** 状态:0=隐藏 1=显示 */ + @Column(name = "status", nullable = false) + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/entity/CmsSite.java b/src/main/java/com/yunhai/cms/entity/CmsSite.java new file mode 100644 index 0000000..9bb38a8 --- /dev/null +++ b/src/main/java/com/yunhai/cms/entity/CmsSite.java @@ -0,0 +1,62 @@ +package com.yunhai.cms.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * CMS 站点(多租户根,方案 §5.1 表1)。 + * + *

站点层级(《院系子站方案》):{@code parent_site_id}=0 为顶层校站点(一校一主站), + * >0 为该院系子站(挂主站 id,层级固定两层)。 + * + *

config_json 保留键(方案 §8.6):{@code domain}(学校绑定域名台账)、 + * {@code public_base_url}(对外基地址,默认派生自 base 域,绑学校域名后覆盖)、 + * {@code colleges_label}(门户"专业院系"下拉文案,默认"专业院系")、 + * {@code wechat}(公众号配置节点,本期不写,见微信平台对接方案)。 + */ +@Getter +@Setter +@Entity +@Table(name = "cms_site", + uniqueConstraints = @UniqueConstraint(name = "uk_site_code", columnNames = "code"), + indexes = @Index(name = "idx_site_sch", columnList = "sch_id")) +public class CmsSite extends BaseEntity { + + /** 站点编码,URL 路径用(/portal/{code}) */ + @Column(name = "code", nullable = false, length = 64) + private String code; + + /** 上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层) */ + @Column(name = "parent_site_id", nullable = false) + private Integer parentSiteId = 0; + + /** 学校ID(逻辑外键 → UserBase sys_group.id) */ + @Column(name = "sch_id") + private Integer schId; + + /** 学校名称(冗余展示) */ + @Column(name = "sch_name", length = 128) + private String schName; + + /** 站点名称 */ + @Column(name = "name", nullable = false, length = 128) + private String name; + + /** 皮肤包名(classpath:/templates/themes/{theme}/) */ + @Column(name = "theme", length = 64) + private String theme = "default"; + + /** 站点配置 JSON(logo/联系方式/页脚/domain/public_base_url) */ + @Column(name = "config_json", columnDefinition = "JSON") + private String configJson; + + /** 状态:0=停用 1=启用 */ + @Column(name = "status", nullable = false) + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/enums/ArticleStatusEnum.java b/src/main/java/com/yunhai/cms/enums/ArticleStatusEnum.java new file mode 100644 index 0000000..ddc30af --- /dev/null +++ b/src/main/java/com/yunhai/cms/enums/ArticleStatusEnum.java @@ -0,0 +1,36 @@ +package com.yunhai.cms.enums; + +/** + * 文章状态:MVP 草稿→发布 两态 + 已下架;审核状态机为演进项(方案 §2/§5.2)。 + */ +public enum ArticleStatusEnum { + + /** 草稿(publish_time 为未来时间时即"定时发布"待触发) */ + DRAFT(0), + /** 已发布 */ + PUBLISHED(1), + /** 已下架 */ + OFFLINE(2); + + private final int code; + + ArticleStatusEnum(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static ArticleStatusEnum byCode(Integer code) { + if (code == null) { + return DRAFT; + } + for (ArticleStatusEnum e : values()) { + if (e.code == code) { + return e; + } + } + return DRAFT; + } +} diff --git a/src/main/java/com/yunhai/cms/enums/CategoryTypeEnum.java b/src/main/java/com/yunhai/cms/enums/CategoryTypeEnum.java new file mode 100644 index 0000000..d0729a1 --- /dev/null +++ b/src/main/java/com/yunhai/cms/enums/CategoryTypeEnum.java @@ -0,0 +1,50 @@ +package com.yunhai.cms.enums; + +/** + * 栏目类型(旧 CMS3.0"频道"层收敛而来,方案 §2/§5.2): + * 文章列表 / 单页 / 外链 三类覆盖原频道全部用法。 + */ +public enum CategoryTypeEnum { + + /** 文章列表 */ + LIST(1), + /** 单页(直接渲染 content) */ + SINGLE(2), + /** 外链(门户 302 跳转) */ + LINK(3); + + private final int code; + + CategoryTypeEnum(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static CategoryTypeEnum byCode(Integer code) { + if (code == null) { + return LIST; + } + for (CategoryTypeEnum e : values()) { + if (e.code == code) { + return e; + } + } + return LIST; + } + + /** 严格解析:未知/空返回 null(用户输入校验用;渲染期脏数据仍走 byCode 宽松回退) */ + public static CategoryTypeEnum ofCode(Integer code) { + if (code == null) { + return null; + } + for (CategoryTypeEnum e : values()) { + if (e.code == code) { + return e; + } + } + return null; + } +} diff --git a/src/main/java/com/yunhai/cms/enums/LinkTypeEnum.java b/src/main/java/com/yunhai/cms/enums/LinkTypeEnum.java new file mode 100644 index 0000000..8ae507d --- /dev/null +++ b/src/main/java/com/yunhai/cms/enums/LinkTypeEnum.java @@ -0,0 +1,50 @@ +package com.yunhai.cms.enums; + +/** + * 跳转类型(轮播 link_type 等通用;方案 §5.1 表5)。 + * 计划中的 LinkTypeEnum/BannerLinkTypeEnum 语义重合,合并为本枚举。 + */ +public enum LinkTypeEnum { + + /** 文章(link_value=文章ID,门户渲染为 /portal/{code}/a/{id}) */ + ARTICLE(1), + /** 栏目(link_value=栏目ID,渲染为 /portal/{code}/c/{id}) */ + CATEGORY(2), + /** 外链(link_value=完整 URL) */ + EXTERNAL(3); + + private final int code; + + LinkTypeEnum(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static LinkTypeEnum byCode(Integer code) { + if (code == null) { + return ARTICLE; + } + for (LinkTypeEnum e : values()) { + if (e.code == code) { + return e; + } + } + return ARTICLE; + } + + /** 严格解析:未知/空返回 null(用户输入校验用;渲染期脏数据仍走 byCode 宽松回退) */ + public static LinkTypeEnum ofCode(Integer code) { + if (code == null) { + return null; + } + for (LinkTypeEnum e : values()) { + if (e.code == code) { + return e; + } + } + return null; + } +} diff --git a/src/main/java/com/yunhai/cms/event/ArticleChangedEvent.java b/src/main/java/com/yunhai/cms/event/ArticleChangedEvent.java new file mode 100644 index 0000000..0ad0e46 --- /dev/null +++ b/src/main/java/com/yunhai/cms/event/ArticleChangedEvent.java @@ -0,0 +1,23 @@ +package com.yunhai.cms.event; + +/** + * 文章内容变更事件(保存修改/下架/删除)。事务提交后触发缓存失效(方案 §8.4)。 + */ +public class ArticleChangedEvent { + + private final Integer articleId; + private final Integer siteId; + + public ArticleChangedEvent(Integer articleId, Integer siteId) { + this.articleId = articleId; + this.siteId = siteId; + } + + public Integer getArticleId() { + return articleId; + } + + public Integer getSiteId() { + return siteId; + } +} diff --git a/src/main/java/com/yunhai/cms/event/ArticlePublishedEvent.java b/src/main/java/com/yunhai/cms/event/ArticlePublishedEvent.java new file mode 100644 index 0000000..8a6b7ab --- /dev/null +++ b/src/main/java/com/yunhai/cms/event/ArticlePublishedEvent.java @@ -0,0 +1,31 @@ +package com.yunhai.cms.event; + +/** + * 文章发布事件(方案 §7.4 微信预留挂点):事务提交后由 AFTER_COMMIT 监听器消费。 + * 本期监听方 = 缓存失效 + WeChatPushService(Noop 实现,零外联); + * 启用《微信平台对接方案》时仅替换 Noop 实现,零表结构变更。 + */ +public class ArticlePublishedEvent { + + private final Integer articleId; + private final Integer siteId; + private final String title; + + public ArticlePublishedEvent(Integer articleId, Integer siteId, String title) { + this.articleId = articleId; + this.siteId = siteId; + this.title = title; + } + + public Integer getArticleId() { + return articleId; + } + + public Integer getSiteId() { + return siteId; + } + + public String getTitle() { + return title; + } +} diff --git a/src/main/java/com/yunhai/cms/event/SiteContentChangedEvent.java b/src/main/java/com/yunhai/cms/event/SiteContentChangedEvent.java new file mode 100644 index 0000000..6f48795 --- /dev/null +++ b/src/main/java/com/yunhai/cms/event/SiteContentChangedEvent.java @@ -0,0 +1,26 @@ +package com.yunhai.cms.event; + +/** + * 站点内容/结构变更事件:站点本身、栏目树、轮播、友链 任一变更即发, + * 门户站点快照缓存(cms:site)整体失效 + 列表缓存清除(方案 §8.4)。 + */ +public class SiteContentChangedEvent { + + private final Integer siteId; + + /** 变更来源说明(日志用):site/category/banner/link */ + private final String scope; + + public SiteContentChangedEvent(Integer siteId, String scope) { + this.siteId = siteId; + this.scope = scope; + } + + public Integer getSiteId() { + return siteId; + } + + public String getScope() { + return scope; + } +} diff --git a/src/main/java/com/yunhai/cms/event/listener/PortalCacheEvictListener.java b/src/main/java/com/yunhai/cms/event/listener/PortalCacheEvictListener.java new file mode 100644 index 0000000..0311eb3 --- /dev/null +++ b/src/main/java/com/yunhai/cms/event/listener/PortalCacheEvictListener.java @@ -0,0 +1,60 @@ +package com.yunhai.cms.event.listener; + +import com.yunhai.cms.config.CacheNames; +import com.yunhai.cms.event.ArticleChangedEvent; +import com.yunhai.cms.event.ArticlePublishedEvent; +import com.yunhai.cms.event.SiteContentChangedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 门户缓存失效监听(方案 §8.4):全部 AFTER_COMMIT(fallbackExecution=true 兼容 + * 定时任务等无事务路径),失效失败仅记日志,靠 TTL 自愈。 + * + *

cms:list / cms:site 的 key 含分页与站点维度,MVP 直接整缓存 clear: + * 单实例 Caffeine、站点 ≤20,clear 成本可忽略;精确 evict 留给 Redis 演进。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PortalCacheEvictListener { + + private final CacheManager cacheManager; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onArticlePublished(ArticlePublishedEvent event) { + evictArticle(event.getArticleId()); + clear(CacheNames.LIST); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onArticleChanged(ArticleChangedEvent event) { + evictArticle(event.getArticleId()); + clear(CacheNames.LIST); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onSiteContentChanged(SiteContentChangedEvent event) { + clear(CacheNames.SITE); + clear(CacheNames.LIST); + } + + private void evictArticle(Integer articleId) { + Cache cache = cacheManager.getCache(CacheNames.ARTICLE); + if (cache != null && articleId != null) { + cache.evict(articleId); + } + } + + private void clear(String cacheName) { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.clear(); + } + } +} diff --git a/src/main/java/com/yunhai/cms/event/listener/WeChatPushListener.java b/src/main/java/com/yunhai/cms/event/listener/WeChatPushListener.java new file mode 100644 index 0000000..912aea0 --- /dev/null +++ b/src/main/java/com/yunhai/cms/event/listener/WeChatPushListener.java @@ -0,0 +1,24 @@ +package com.yunhai.cms.event.listener; + +import com.yunhai.cms.event.ArticlePublishedEvent; +import com.yunhai.cms.service.WeChatPushService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 文章发布 → 微信推送挂点(方案 §7.4 后端层预留)。 + * 事务提交后才触发,保证推送方读到的数据已落库;推送失败不影响业务事务。 + */ +@Component +@RequiredArgsConstructor +public class WeChatPushListener { + + private final WeChatPushService weChatPushService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onArticlePublished(ArticlePublishedEvent event) { + weChatPushService.onArticlePublished(event); + } +} diff --git a/src/main/java/com/yunhai/cms/model/dto/ArticleSaveRequest.java b/src/main/java/com/yunhai/cms/model/dto/ArticleSaveRequest.java new file mode 100644 index 0000000..db6559b --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/ArticleSaveRequest.java @@ -0,0 +1,64 @@ +package com.yunhai.cms.model.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.time.LocalDateTime; + +/** + * 文章保存(草稿,content 先过 jsoup 白名单,方案 §9.2)。 + * 发布语义见 ArticleService.publish:publishTime 为未来时间即"定时发布"。 + */ +@Data +public class ArticleSaveRequest { + + /** 修改时必填 */ + private Integer id; + + @NotNull(message = "siteId 不得为空") + private Integer siteId; + + @NotNull(message = "所属栏目不得为空") + private Integer categoryId; + + @NotBlank(message = "标题不得为空") + @Size(max = 200, message = "标题过长") + private String title; + + /** 摘要(空=服务端截取正文) */ + @Size(max = 500, message = "摘要过长") + private String summary; + + /** 正文 HTML(服务端 jsoup 清洗后入库) */ + private String content; + + @Size(max = 500) + private String coverImage; + + /** 作者姓名(展示用;账号发布时自动从 BoaUser 填充) */ + @Size(max = 64) + private String authorName; + + @Size(max = 128) + private String source; + + /** 置顶:0/1 */ + private Integer topmost = 0; + + /** 定时发布时间(空=不指定,发布动作时取当前时间) */ + private LocalDateTime publishTime; + + /** 定时下架时间(可空) */ + private LocalDateTime offlineTime; + + /** + * 同步到公众号开关(微信预留入口,本期恒禁用,方案 §7.4)。 + * 仅作占位字段,后端不消费;启用后写 ext_json.wechat。 + */ + private Boolean syncWechat = false; + + /** 本次编辑上传的附件 id 列表(保存时回填 article_id 归属,方案 §5.1 表4) */ + private java.util.List attachmentIds; +} diff --git a/src/main/java/com/yunhai/cms/model/dto/BannerSaveRequest.java b/src/main/java/com/yunhai/cms/model/dto/BannerSaveRequest.java new file mode 100644 index 0000000..71252d6 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/BannerSaveRequest.java @@ -0,0 +1,52 @@ +package com.yunhai.cms.model.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.time.LocalDateTime; + +/** + * 轮播保存(校验 link_type/link_value,方案 §9.2)。 + */ +@Data +public class BannerSaveRequest { + + /** 修改时必填 */ + private Integer id; + + @NotNull(message = "siteId 不得为空") + private Integer siteId; + + /** 展示位(home=首页轮播) */ + @Size(max = 32) + private String position = "home"; + + @NotBlank(message = "标题不得为空") + @Size(max = 200, message = "标题过长") + private String title; + + @NotBlank(message = "图片不得为空") + @Size(max = 500) + private String imageUrl; + + /** 1=文章 2=栏目 3=外链 */ + @NotNull(message = "跳转类型不得为空") + private Integer linkType; + + /** 跳转目标:文章ID/栏目ID/URL(服务端按类型校验必填与存在性) */ + @Size(max = 500, message = "跳转目标过长") + private String linkValue; + + private Integer sortNo = 0; + + /** 上线时间(空=立即) */ + private LocalDateTime startTime; + + /** 下线时间(可空) */ + private LocalDateTime endTime; + + /** 0=停用 1=启用 */ + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/model/dto/CategorySaveRequest.java b/src/main/java/com/yunhai/cms/model/dto/CategorySaveRequest.java new file mode 100644 index 0000000..96c644c --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/CategorySaveRequest.java @@ -0,0 +1,54 @@ +package com.yunhai.cms.model.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +/** + * 栏目保存(新增 id 空 / 修改 id 必填,方案 §9.2)。 + */ +@Data +public class CategorySaveRequest { + + /** 修改时必填 */ + private Integer id; + + @NotNull(message = "siteId 不得为空") + private Integer siteId; + + /** 父栏目ID(0=根) */ + @NotNull(message = "父栏目不得为空") + private Integer parentId; + + @NotBlank(message = "栏目名称不得为空") + @Size(max = 64, message = "栏目名称过长") + private String name; + + /** 1=文章列表 2=单页 3=外链 */ + @NotNull(message = "栏目类型不得为空") + private Integer type; + + /** 外链地址(type=3 必填,服务端校验) */ + @Size(max = 500, message = "外链地址过长") + private String linkUrl; + + /** 栏目模板覆盖(预留能力) */ + @Size(max = 64) + private String template; + + /** 单页栏目内容(type=2 有效,服务端 jsoup 清洗后入库) */ + private String content; + + private Integer sortNo = 0; + + @Size(max = 200) + private String seoTitle; + + @Size(max = 500) + private String seoDescription; + + /** 0=隐藏 1=显示 */ + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/model/dto/CategorySortRequest.java b/src/main/java/com/yunhai/cms/model/dto/CategorySortRequest.java new file mode 100644 index 0000000..8a888a2 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/CategorySortRequest.java @@ -0,0 +1,25 @@ +package com.yunhai.cms.model.dto; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * 子栏目排序入参(UserBase SysGroupSortRequest 同语义:ids 顺序即 sortNo 1..n)。 + */ +@Data +public class CategorySortRequest { + + /** 站点 ID(权限校验用) */ + @NotNull(message = "站点ID不能为空") + private Integer siteId; + + /** 父栏目 ID(0=根栏目下的排序) */ + @NotNull(message = "父栏目ID不能为空") + private Integer parentId; + + /** 期望的、parentId 直接子栏目有序 ID 列表(按传入顺序生效) */ + @NotNull(message = "子栏目ID列表不能为空") + private List ids; +} diff --git a/src/main/java/com/yunhai/cms/model/dto/DeleteRequest.java b/src/main/java/com/yunhai/cms/model/dto/DeleteRequest.java new file mode 100644 index 0000000..ffdb51a --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/DeleteRequest.java @@ -0,0 +1,18 @@ +package com.yunhai.cms.model.dto; + +import lombok.Data; + +import javax.validation.constraints.NotNull; + +/** + * 通用删除入参:id + siteId(siteId 用于站点归属权限校验,避免越权 id 枚举)。 + */ +@Data +public class DeleteRequest { + + @NotNull(message = "id 不得为空") + private Integer id; + + @NotNull(message = "siteId 不得为空") + private Integer siteId; +} diff --git a/src/main/java/com/yunhai/cms/model/dto/LinkSaveRequest.java b/src/main/java/com/yunhai/cms/model/dto/LinkSaveRequest.java new file mode 100644 index 0000000..61d5d00 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/LinkSaveRequest.java @@ -0,0 +1,41 @@ +package com.yunhai.cms.model.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +/** + * 友链保存(方案 §9.2)。 + */ +@Data +public class LinkSaveRequest { + + /** 修改时必填 */ + private Integer id; + + @NotNull(message = "siteId 不得为空") + private Integer siteId; + + /** 分组(上级部门/兄弟院校/合作单位…,空=default) */ + @Size(max = 64, message = "分组名过长") + private String groupName = "default"; + + @NotBlank(message = "链接名称不得为空") + @Size(max = 128, message = "链接名称过长") + private String name; + + /** Logo(可空,文字链) */ + @Size(max = 500) + private String logoUrl; + + @NotBlank(message = "链接地址不得为空") + @Size(max = 500, message = "链接地址过长") + private String linkUrl; + + private Integer sortNo = 0; + + /** 0=隐藏 1=显示 */ + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/model/dto/SiteSaveRequest.java b/src/main/java/com/yunhai/cms/model/dto/SiteSaveRequest.java new file mode 100644 index 0000000..f651ed7 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/SiteSaveRequest.java @@ -0,0 +1,54 @@ +package com.yunhai.cms.model.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; +import java.util.Map; + +/** + * 站点保存(仅超管可见,方案 §9.1/§7.2)。 + * MVP 1-2 校:初始化脚本建站即可,本接口为超管后台兜底入口。 + */ +@Data +public class SiteSaveRequest { + + /** 修改时必填 */ + private Integer id; + + @NotBlank(message = "站点编码不得为空") + @Pattern(regexp = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$", message = "编码仅限小写字母/数字/短横线") + private String code; + + /** 上级站点ID(空或 0=顶层校站点;院系子站挂主站 id,《院系子站方案》) */ + private Integer parentSiteId; + + private Integer schId; + + @Size(max = 128) + private String schName; + + @NotBlank(message = "站点名称不得为空") + @Size(max = 128, message = "站点名称过长") + private String name; + + /** 皮肤包名(空=default) */ + @Size(max = 64) + private String theme; + + /** + * 站点配置 JSON(logo/联系方式/页脚;保留键 domain/public_base_url/wechat,方案 §8.6)。 + * 服务端做 JSON 合法性校验。 + */ + private String configJson; + + /** + * 主题微调参数(方案 §5.3):仅接收所选主题 theme.json 声明过的 key; + * 服务端校验后合并进 config_json.theme_params(保留其余键),非法 key/值服务端丢弃。 + */ + private Map themeParams; + + /** 0=停用 1=启用 */ + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalArticleDetail.java b/src/main/java/com/yunhai/cms/model/portal/PortalArticleDetail.java new file mode 100644 index 0000000..1ca9d39 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/portal/PortalArticleDetail.java @@ -0,0 +1,72 @@ +package com.yunhai.cms.model.portal; + +import com.yunhai.cms.entity.CmsArticle; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 门户文章详情(cms:article 缓存体;已发布文章才缓存,null 哨兵 30s 防穿透)。 + */ +@Data +public class PortalArticleDetail { + + private Integer id; + + private Integer siteId; + + private Integer categoryId; + + private String title; + + /** 正文(入库前已 jsoup 清洗,模板 th:utext 输出) */ + private String content; + + private String summary; + + private String coverImage; + + private String authorName; + + private String source; + + private LocalDateTime publishTime; + + private Integer viewCount; + + public static PortalArticleDetail of(CmsArticle a) { + PortalArticleDetail detail = new PortalArticleDetail(); + detail.setId(a.getId()); + detail.setSiteId(a.getSiteId()); + detail.setCategoryId(a.getCategoryId()); + detail.setTitle(a.getTitle()); + detail.setContent(a.getContent()); + detail.setSummary(a.getSummary()); + detail.setCoverImage(a.getCoverImage()); + detail.setAuthorName(a.getAuthorName()); + detail.setSource(a.getSource()); + detail.setPublishTime(a.getPublishTime()); + detail.setViewCount(a.getViewCount()); + return detail; + } + + /** + * 渲染副本(B-1):本对象是 cms:article 缓存体(两种暴露形态共享), + * 正文上传地址需按当前请求基准重写,故拷贝后替换 content,不回写缓存。 + */ + public PortalArticleDetail withContent(String content) { + PortalArticleDetail copy = new PortalArticleDetail(); + copy.id = id; + copy.siteId = siteId; + copy.categoryId = categoryId; + copy.title = title; + copy.content = content; + copy.summary = summary; + copy.coverImage = coverImage; + copy.authorName = authorName; + copy.source = source; + copy.publishTime = publishTime; + copy.viewCount = viewCount; + return copy; + } +} diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalBanner.java b/src/main/java/com/yunhai/cms/model/portal/PortalBanner.java new file mode 100644 index 0000000..8a2ed11 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/portal/PortalBanner.java @@ -0,0 +1,50 @@ +package com.yunhai.cms.model.portal; + +import com.yunhai.cms.entity.CmsBanner; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 门户轮播项(启用态;定时上下线由渲染方按时间窗过滤)。 + */ +@Data +public class PortalBanner { + + private Integer id; + + /** 展示位(home=首页轮播) */ + private String position; + + private String title; + + private String imageUrl; + + /** 1=文章 2=栏目 3=外链 */ + private Integer linkType; + + /** 跳转目标原值(文章ID/栏目ID/URL),渲染时解析为完整 URL */ + private String linkValue; + + private Integer sortNo; + + /** 上线时间(渲染方过滤) */ + private LocalDateTime startTime; + + /** 下线时间(渲染方过滤) */ + private LocalDateTime endTime; + + public static PortalBanner of(CmsBanner b) { + PortalBanner item = new PortalBanner(); + item.setId(b.getId()); + item.setPosition(b.getPosition()); + item.setTitle(b.getTitle()); + item.setImageUrl(b.getImageUrl()); + item.setLinkType(b.getLinkType()); + item.setLinkValue(b.getLinkValue()); + item.setSortNo(b.getSortNo()); + item.setStartTime(b.getStartTime()); + item.setEndTime(b.getEndTime()); + return item; + } +} diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalCategory.java b/src/main/java/com/yunhai/cms/model/portal/PortalCategory.java new file mode 100644 index 0000000..bc112f0 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/portal/PortalCategory.java @@ -0,0 +1,69 @@ +package com.yunhai.cms.model.portal; + +import com.yunhai.cms.entity.CmsCategory; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 门户栏目节点(仅启用项;单页栏目带 content)。 + */ +@Data +public class PortalCategory { + + private Integer id; + + private Integer parentId; + + private String name; + + /** 1=文章列表 2=单页 3=外链 */ + private Integer type; + + private String linkUrl; + + private String template; + + private String seoTitle; + + private String seoDescription; + + /** 单页内容(type=2,已清洗 HTML) */ + private String content; + + private List children = new ArrayList<>(); + + public static PortalCategory of(CmsCategory c) { + PortalCategory node = new PortalCategory(); + node.setId(c.getId()); + node.setParentId(c.getParentId()); + node.setName(c.getName()); + node.setType(c.getType()); + node.setLinkUrl(c.getLinkUrl()); + node.setTemplate(c.getTemplate()); + node.setSeoTitle(c.getSeoTitle()); + node.setSeoDescription(c.getSeoDescription()); + node.setContent(c.getContent()); + return node; + } + + /** + * 渲染副本(B-1):本对象挂在 cms:site 快照缓存上, + * 单页正文上传地址按当前请求基准重写需在拷贝上进行,不回写缓存。 + */ + public PortalCategory withContent(String content) { + PortalCategory copy = new PortalCategory(); + copy.id = id; + copy.parentId = parentId; + copy.name = name; + copy.type = type; + copy.linkUrl = linkUrl; + copy.template = template; + copy.seoTitle = seoTitle; + copy.seoDescription = seoDescription; + copy.content = content; + copy.children = children; + return copy; + } +} diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalLinkGroup.java b/src/main/java/com/yunhai/cms/model/portal/PortalLinkGroup.java new file mode 100644 index 0000000..cd924a1 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/portal/PortalLinkGroup.java @@ -0,0 +1,27 @@ +package com.yunhai.cms.model.portal; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 门户友链分组(页脚/首页区块渲染,方案 §8.1:不设独立路由)。 + */ +@Data +public class PortalLinkGroup { + + private String groupName; + + private List links = new ArrayList<>(); + + @Data + public static class PortalLink { + + private String name; + + private String logoUrl; + + private String linkUrl; + } +} diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalListData.java b/src/main/java/com/yunhai/cms/model/portal/PortalListData.java new file mode 100644 index 0000000..298b059 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/portal/PortalListData.java @@ -0,0 +1,44 @@ +package com.yunhai.cms.model.portal; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 门户列表页数据(cms:list 缓存体,key=categoryId:page)。 + */ +@Data +public class PortalListData { + + private Integer categoryId; + + private Integer siteId; + + private List items = new ArrayList<>(); + + private long total; + + private int page; + + private int pageSize; + + private int totalPages; + + @Data + public static class Item { + + private Integer id; + + private String title; + + private String summary; + + private String coverImage; + + private LocalDateTime publishTime; + + private Integer topmost; + } +} diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalSiteBrief.java b/src/main/java/com/yunhai/cms/model/portal/PortalSiteBrief.java new file mode 100644 index 0000000..07aeb61 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/portal/PortalSiteBrief.java @@ -0,0 +1,39 @@ +package com.yunhai.cms.model.portal; + +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.utils.ExtJsonUtils; +import lombok.Data; + +import java.util.Map; + +/** + * 院系子站精简态(门户"专业院系"下拉数据源,《院系子站方案》)。 + * 链接不进快照:由渲染方经 {@link com.yunhai.cms.utils.PortalUrlHelper#siteHome} + * 生成(该站 public_base_url 优先,未配置按当前请求基准派生,方案 §8.6 前置约定2)。 + */ +@Data +public class PortalSiteBrief { + + private Integer id; + + private String code; + + private String name; + + /** 站点 logo(config_json.logo,下拉项可选展示) */ + private String logoUrl; + + /** 对外基地址(config_json.public_base_url,未配置时渲染方按当前请求派生) */ + private String publicBaseUrl; + + public static PortalSiteBrief of(CmsSite site) { + PortalSiteBrief brief = new PortalSiteBrief(); + brief.setId(site.getId()); + brief.setCode(site.getCode()); + brief.setName(site.getName()); + Map config = ExtJsonUtils.parse(site.getConfigJson()); + brief.setLogoUrl(ExtJsonUtils.getString(config, "logo")); + brief.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url")); + return brief; + } +} diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java new file mode 100644 index 0000000..7729879 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java @@ -0,0 +1,99 @@ +package com.yunhai.cms.model.portal; + +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.utils.ExtJsonUtils; +import lombok.Data; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 门户站点快照(cms:site 缓存体,方案 §8.4): + * 站点配置 + 栏目树 + 轮播位 + 友链分组 + 同校院系子站。文章数据不进快照(cms:list / cms:article 独立缓存)。 + */ +@Data +public class PortalSiteSnapshot { + + private Integer id; + + private String code; + + /** 上级站点ID(0=顶层校站点,《院系子站方案》) */ + private Integer parentSiteId; + + private String name; + + private String schName; + + /** 皮肤包名(缺省/default 回退已在装配时处理) */ + private String theme; + + /** 站点配置(logo/contact/footer 等,模板经 config['key'] 取用) */ + private Map config; + + /** 站点绑定域名(config_json.domain,台账用) */ + private String domain; + + /** 对外基地址(config_json.public_base_url,未配置时渲染方按请求派生) */ + private String publicBaseUrl; + + /** 启用栏目树(status=1,已组树) */ + private List categories = new ArrayList<>(); + + /** 启用轮播(status=1 全部位,时间窗过滤在渲染方) */ + private List banners = new ArrayList<>(); + + /** 启用友链分组 */ + private List linkGroups = new ArrayList<>(); + + /** 同校院系子站("专业院系"下拉数据源;顶层站无子站/子站的兄弟站集合,空则模板不渲染) */ + private List colleges = new ArrayList<>(); + + /** 微调参数(仅所选主题 theme.json 声明过的 key 且值合法,方案 §5.3) */ + private Map themeParams = new LinkedHashMap<>(); + + /** 服务端预构建的 :root{...} CSS 变量覆盖串(无有效参数时为空串,方案 §5.3) */ + private String themeVarsCss = ""; + + public static PortalSiteSnapshot of(CmsSite site) { + PortalSiteSnapshot snapshot = new PortalSiteSnapshot(); + snapshot.setId(site.getId()); + snapshot.setCode(site.getCode()); + snapshot.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId()); + snapshot.setName(site.getName()); + snapshot.setSchName(site.getSchName()); + snapshot.setTheme(site.getTheme() == null || site.getTheme().isEmpty() + ? com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME : site.getTheme()); + Map config = ExtJsonUtils.parse(site.getConfigJson()); + snapshot.setConfig(config); + snapshot.setDomain(ExtJsonUtils.getString(config, "domain")); + snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url")); + return snapshot; + } + + /** + * 预览用浅拷贝(方案 §5.5):集合字段共享只读引用;覆盖 theme/themeParams/themeVarsCss + * 的操作由预览方在拷贝上进行,不影响缓存中的原快照。 + */ + public PortalSiteSnapshot shallowCopy() { + PortalSiteSnapshot copy = new PortalSiteSnapshot(); + copy.setId(id); + copy.setCode(code); + copy.setParentSiteId(parentSiteId); + copy.setName(name); + copy.setSchName(schName); + copy.setTheme(theme); + copy.setConfig(config); + copy.setDomain(domain); + copy.setPublicBaseUrl(publicBaseUrl); + copy.setCategories(categories); + copy.setBanners(banners); + copy.setLinkGroups(linkGroups); + copy.setColleges(colleges); + copy.setThemeParams(themeParams); + copy.setThemeVarsCss(themeVarsCss); + return copy; + } +} diff --git a/src/main/java/com/yunhai/cms/model/query/ArticlePageQuery.java b/src/main/java/com/yunhai/cms/model/query/ArticlePageQuery.java new file mode 100644 index 0000000..38c10f5 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/query/ArticlePageQuery.java @@ -0,0 +1,29 @@ +package com.yunhai.cms.model.query; + +import lombok.Data; + +import java.util.List; + +/** + * 文章分页查询(方案 §9.2:siteId/categoryId/status/关键字)。 + */ +@Data +public class ArticlePageQuery { + + private Integer siteId; + + /** 栏目 id(可空;支持多选时前端传逗号分隔,MVP 单选/空=全部) */ + private List categoryIds; + + /** 状态:空=全部,0=草稿 1=已发布 2=已下架 */ + private Integer status; + + /** 标题关键字 */ + private String keyword; + + /** 页码,1 起 */ + private Integer page = 1; + + /** 页长 */ + private Integer pageSize = 10; +} diff --git a/src/main/java/com/yunhai/cms/model/vo/ArticleEditVo.java b/src/main/java/com/yunhai/cms/model/vo/ArticleEditVo.java new file mode 100644 index 0000000..0f158e6 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/ArticleEditVo.java @@ -0,0 +1,62 @@ +package com.yunhai.cms.model.vo; + +import com.yunhai.cms.entity.CmsArticle; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 文章编辑回显 VO(含正文与扩展列)。 + */ +@Data +public class ArticleEditVo { + + private Integer id; + + private Integer siteId; + + private Integer categoryId; + + private String title; + + private String summary; + + /** 正文 HTML(已清洗入库内容原样回显) */ + private String content; + + private String coverImage; + + private String authorName; + + private String source; + + private Integer status; + + private Integer topmost; + + private LocalDateTime publishTime; + + private LocalDateTime offlineTime; + + /** 扩展列 JSON 原文(wechat/albums 预留键) */ + private String extJson; + + public static ArticleEditVo of(CmsArticle a) { + ArticleEditVo vo = new ArticleEditVo(); + vo.setId(a.getId()); + vo.setSiteId(a.getSiteId()); + vo.setCategoryId(a.getCategoryId()); + vo.setTitle(a.getTitle()); + vo.setSummary(a.getSummary()); + vo.setContent(a.getContent()); + vo.setCoverImage(a.getCoverImage()); + vo.setAuthorName(a.getAuthorName()); + vo.setSource(a.getSource()); + vo.setStatus(a.getStatus()); + vo.setTopmost(a.getTopmost()); + vo.setPublishTime(a.getPublishTime()); + vo.setOfflineTime(a.getOfflineTime()); + vo.setExtJson(a.getExtJson()); + return vo; + } +} diff --git a/src/main/java/com/yunhai/cms/model/vo/ArticlePageItem.java b/src/main/java/com/yunhai/cms/model/vo/ArticlePageItem.java new file mode 100644 index 0000000..01921d7 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/ArticlePageItem.java @@ -0,0 +1,62 @@ +package com.yunhai.cms.model.vo; + +import com.yunhai.cms.entity.CmsArticle; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 文章分页行(列表页,方案 §9.1 文章列表)。 + */ +@Data +public class ArticlePageItem { + + private Integer id; + + private Integer siteId; + + private Integer categoryId; + + private String categoryName; + + private String title; + + private String summary; + + private String coverImage; + + /** 0=草稿 1=已发布 2=已下架 */ + private Integer status; + + private Integer topmost; + + /** 发布时间(草稿且未来时间为"定时发布") */ + private LocalDateTime publishTime; + + private LocalDateTime offlineTime; + + private Integer viewCount; + + private LocalDateTime createTime; + + private String createBy; + + public static ArticlePageItem of(CmsArticle a, String categoryName) { + ArticlePageItem item = new ArticlePageItem(); + item.setId(a.getId()); + item.setSiteId(a.getSiteId()); + item.setCategoryId(a.getCategoryId()); + item.setCategoryName(categoryName); + item.setTitle(a.getTitle()); + item.setSummary(a.getSummary()); + item.setCoverImage(a.getCoverImage()); + item.setStatus(a.getStatus()); + item.setTopmost(a.getTopmost()); + item.setPublishTime(a.getPublishTime()); + item.setOfflineTime(a.getOfflineTime()); + item.setViewCount(a.getViewCount()); + item.setCreateTime(a.getCreateTime()); + item.setCreateBy(a.getCreateBy()); + return item; + } +} diff --git a/src/main/java/com/yunhai/cms/model/vo/AttachmentVo.java b/src/main/java/com/yunhai/cms/model/vo/AttachmentVo.java new file mode 100644 index 0000000..25ba7b6 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/AttachmentVo.java @@ -0,0 +1,45 @@ +package com.yunhai.cms.model.vo; + +import com.yunhai.cms.entity.CmsAttachment; +import lombok.Data; + +/** + * 附件 VO(上传返回 + 附件管理列表)。 + */ +@Data +public class AttachmentVo { + + private Integer id; + + private Integer siteId; + + private Integer articleId; + + private String fileName; + + /** Web 访问 URL(编辑器/封面/轮播直接引用) */ + private String url; + + private Long fileSize; + + private String mimeType; + + private String createTime; + + /** 是否孤儿(文章已删除/未关联) */ + private boolean orphan; + + public static AttachmentVo of(CmsAttachment a) { + AttachmentVo vo = new AttachmentVo(); + vo.setId(a.getId()); + vo.setSiteId(a.getSiteId()); + vo.setArticleId(a.getArticleId()); + vo.setFileName(a.getFileName()); + vo.setUrl(a.getWebUrl()); + vo.setFileSize(a.getFileSize()); + vo.setMimeType(a.getMimeType()); + vo.setCreateTime(a.getCreateTime() == null ? null : a.getCreateTime().toString()); + vo.setOrphan(a.getArticleId() == null); + return vo; + } +} diff --git a/src/main/java/com/yunhai/cms/model/vo/AuthUserVo.java b/src/main/java/com/yunhai/cms/model/vo/AuthUserVo.java new file mode 100644 index 0000000..fadd1cd --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/AuthUserVo.java @@ -0,0 +1,45 @@ +package com.yunhai.cms.model.vo; + +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 管理端登录态:CAS 会话用户 + 可管理站点 + 是否超管(方案 §7.2 权限规则的客户端镜像)。 + */ +@Data +@Builder +public class AuthUserVo { + + /** 登录账号 */ + private String account; + + /** 姓名 */ + private String name; + + /** 当前角色(roleId/roleName/schId/schName) */ + private BoaRoleInfo currentRole; + + /** 是否平台超管(BoaUser.type == 1,平台约定) */ + private boolean superAdmin; + + /** 可管理的站点(超管=全部;校级=本校站点) */ + private List sites; + + /** 角色集合(前端判断用) */ + private List roleIds; + + public static AuthUserVo of(BoaUser user, boolean superAdmin, List sites) { + return AuthUserVo.builder() + .account(user.getAccount()) + .name(user.getName()) + .currentRole(user.getCurrentRole()) + .superAdmin(superAdmin) + .sites(sites) + .roleIds(user.getRoleIds()) + .build(); + } +} diff --git a/src/main/java/com/yunhai/cms/model/vo/BannerVo.java b/src/main/java/com/yunhai/cms/model/vo/BannerVo.java new file mode 100644 index 0000000..1b91291 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/BannerVo.java @@ -0,0 +1,52 @@ +package com.yunhai.cms.model.vo; + +import com.yunhai.cms.entity.CmsBanner; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 轮播 VO。 + */ +@Data +public class BannerVo { + + private Integer id; + + private Integer siteId; + + private String position; + + private String title; + + private String imageUrl; + + /** 1=文章 2=栏目 3=外链 */ + private Integer linkType; + + private String linkValue; + + private Integer sortNo; + + private LocalDateTime startTime; + + private LocalDateTime endTime; + + private Integer status; + + public static BannerVo of(CmsBanner b) { + BannerVo vo = new BannerVo(); + vo.setId(b.getId()); + vo.setSiteId(b.getSiteId()); + vo.setPosition(b.getPosition()); + vo.setTitle(b.getTitle()); + vo.setImageUrl(b.getImageUrl()); + vo.setLinkType(b.getLinkType()); + vo.setLinkValue(b.getLinkValue()); + vo.setSortNo(b.getSortNo()); + vo.setStartTime(b.getStartTime()); + vo.setEndTime(b.getEndTime()); + vo.setStatus(b.getStatus()); + return vo; + } +} diff --git a/src/main/java/com/yunhai/cms/model/vo/CategoryTreeNode.java b/src/main/java/com/yunhai/cms/model/vo/CategoryTreeNode.java new file mode 100644 index 0000000..357d656 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/CategoryTreeNode.java @@ -0,0 +1,60 @@ +package com.yunhai.cms.model.vo; + +import com.yunhai.cms.entity.CmsCategory; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 栏目树节点(管理端树控件与门户导航共用结构)。 + */ +@Data +public class CategoryTreeNode { + + private Integer id; + + private Integer parentId; + + private Integer siteId; + + private String name; + + /** 1=文章列表 2=单页 3=外链 */ + private Integer type; + + private String linkUrl; + + private String template; + + /** 单页栏目内容(type=2 时编辑/渲染用) */ + private String content; + + private Integer sortNo; + + private String seoTitle; + + private String seoDescription; + + /** 0=隐藏 1=显示 */ + private Integer status; + + private List children = new ArrayList<>(); + + public static CategoryTreeNode of(CmsCategory c) { + CategoryTreeNode node = new CategoryTreeNode(); + node.setId(c.getId()); + node.setParentId(c.getParentId()); + node.setSiteId(c.getSiteId()); + node.setName(c.getName()); + node.setType(c.getType()); + node.setLinkUrl(c.getLinkUrl()); + node.setTemplate(c.getTemplate()); + node.setContent(c.getContent()); + node.setSortNo(c.getSortNo()); + node.setSeoTitle(c.getSeoTitle()); + node.setSeoDescription(c.getSeoDescription()); + node.setStatus(c.getStatus()); + return node; + } +} diff --git a/src/main/java/com/yunhai/cms/model/vo/LinkVo.java b/src/main/java/com/yunhai/cms/model/vo/LinkVo.java new file mode 100644 index 0000000..98b6fef --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/LinkVo.java @@ -0,0 +1,40 @@ +package com.yunhai.cms.model.vo; + +import com.yunhai.cms.entity.CmsLink; +import lombok.Data; + +/** + * 友链 VO。 + */ +@Data +public class LinkVo { + + private Integer id; + + private Integer siteId; + + private String groupName; + + private String name; + + private String logoUrl; + + private String linkUrl; + + private Integer sortNo; + + private Integer status; + + public static LinkVo of(CmsLink l) { + LinkVo vo = new LinkVo(); + vo.setId(l.getId()); + vo.setSiteId(l.getSiteId()); + vo.setGroupName(l.getGroupName()); + vo.setName(l.getName()); + vo.setLogoUrl(l.getLogoUrl()); + vo.setLinkUrl(l.getLinkUrl()); + vo.setSortNo(l.getSortNo()); + vo.setStatus(l.getStatus()); + return vo; + } +} diff --git a/src/main/java/com/yunhai/cms/model/vo/SchoolVo.java b/src/main/java/com/yunhai/cms/model/vo/SchoolVo.java new file mode 100644 index 0000000..27ea7a1 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/SchoolVo.java @@ -0,0 +1,43 @@ +package com.yunhai.cms.model.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 学校工作台聚合 VO(docs/09 §3.3,D6-4):CMS 不设学校表, + * 学校卡片 = 顶层站点(主站),院系子站经 parentSiteId 归属,schId/schName 冗余展示。 + */ +@Data +public class SchoolVo { + + /** 学校 ID(UserBase sys_group.id,主站冗余字段) */ + private Integer schId; + + private String schName; + + /** 主站(顶层站点) */ + private Integer mainSiteId; + /** 主站编码(学校上下文 URL 段 /sch/{code}) */ + private String mainSiteCode; + private String mainSiteName; + private String theme; + + /** 该主站下站点总数(含主站自身) */ + private Integer siteCount; + + /** 该校全部站点文章总数(管理端规模口径,含未发布) */ + private Long articleCount; + + /** 主站状态:1 启用 / 0 停用 */ + private Integer status; + + /** UserBase 学校选项(「添加学校」选择器用) */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Option { + private Integer id; + private String name; + } +} diff --git a/src/main/java/com/yunhai/cms/model/vo/SiteVo.java b/src/main/java/com/yunhai/cms/model/vo/SiteVo.java new file mode 100644 index 0000000..7f82b27 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/SiteVo.java @@ -0,0 +1,51 @@ +package com.yunhai.cms.model.vo; + +import lombok.Data; + +/** + * 站点 VO。 + */ +@Data +public class SiteVo { + + private Integer id; + + private String code; + + /** 上级站点ID(0=顶层校站点,《院系子站方案》) */ + private Integer parentSiteId; + + /** 上级站点名称(列表展示用,服务端装配) */ + private String parentName; + + private Integer schId; + + private String schName; + + private String name; + + private String theme; + + /** 站点配置 JSON 原文(domain/public_base_url/logo 等) */ + private String configJson; + + /** 0=停用 1=启用 */ + private Integer status; + + private String createTime; + + public static SiteVo of(com.yunhai.cms.entity.CmsSite site) { + SiteVo vo = new SiteVo(); + vo.setId(site.getId()); + vo.setCode(site.getCode()); + vo.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId()); + vo.setSchId(site.getSchId()); + vo.setSchName(site.getSchName()); + vo.setName(site.getName()); + vo.setTheme(site.getTheme()); + vo.setConfigJson(site.getConfigJson()); + vo.setStatus(site.getStatus()); + vo.setCreateTime(site.getCreateTime() == null ? null : site.getCreateTime().toString()); + return vo; + } +} diff --git a/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java b/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java new file mode 100644 index 0000000..cd074c1 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java @@ -0,0 +1,75 @@ +package com.yunhai.cms.model.vo; + +import com.yunhai.cms.theme.ThemeInfo; +import com.yunhai.cms.theme.ThemeParam; +import lombok.Data; + +import java.util.List; + +/** + * 主题包 VO(管理端主题列表,方案 §5.2)。 + */ +@Data +public class ThemeVo { + + private String name; + + private String title; + + private String description; + + private String version; + + private String author; + + private String bannerSize; + + /** 预览图 URL(管理端资产代理 /api/theme/{name}/asset/screenshot.png,方案 §5.2) */ + private String screenshotUrl; + + /** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */ + private List params; + + @Data + public static class ThemeParamVo { + + private String key; + + private String cssVar; + + private String label; + + private String type; + + private String defaultValue; + + private List options; + + public static ThemeParamVo of(ThemeParam p) { + ThemeParamVo vo = new ThemeParamVo(); + vo.setKey(p.getKey()); + vo.setCssVar(p.getCssVar()); + vo.setLabel(p.getLabel()); + vo.setType(p.getType()); + vo.setDefaultValue(p.getDefaultValue()); + vo.setOptions(p.getOptions()); + return vo; + } + } + + public static ThemeVo of(ThemeInfo info) { + ThemeVo vo = new ThemeVo(); + vo.setName(info.getName()); + vo.setTitle(info.getTitle()); + vo.setDescription(info.getDescription()); + vo.setVersion(info.getVersion()); + vo.setAuthor(info.getAuthor()); + vo.setBannerSize(info.getBannerSize()); + vo.setScreenshotUrl("/api/theme/" + info.getName() + "/asset/screenshot.png"); + vo.setParams(new java.util.ArrayList<>()); + for (ThemeParam p : info.getParams()) { + vo.getParams().add(ThemeParamVo.of(p)); + } + return vo; + } +} diff --git a/src/main/java/com/yunhai/cms/repository/CmsArticleRepository.java b/src/main/java/com/yunhai/cms/repository/CmsArticleRepository.java new file mode 100644 index 0000000..96f344d --- /dev/null +++ b/src/main/java/com/yunhai/cms/repository/CmsArticleRepository.java @@ -0,0 +1,73 @@ +package com.yunhai.cms.repository; + +import com.yunhai.cms.entity.CmsArticle; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface CmsArticleRepository extends JpaRepository, + JpaSpecificationExecutor { + + Optional findByIdAndSiteId(Integer id, Integer siteId); + + long countByCategoryId(Integer categoryId); + + /** 学校工作台规模计数(docs/09 §3.3,管理端口径含未发布) */ + long countBySiteId(Integer siteId); + + /** 栏目列表页:已发布,发布时间倒序(置顶由 Specification/排序组合处理) */ + Page findBySiteIdAndCategoryIdAndStatus(Integer siteId, Integer categoryId, + Integer status, Pageable pageable); + + /** 栏目聚合查询(D1-2/D1-3):父栏目区块/列表页聚合列表型子孙栏目的文章 */ + Page findBySiteIdAndCategoryIdInAndStatus(Integer siteId, Collection categoryIds, + Integer status, Pageable pageable); + + /** 门户:首页按站点取已发布(置顶优先),条数由 Pageable 控制 */ + List findBySiteIdAndStatus(Integer siteId, Integer status, Pageable pageable); + + /** + * 详情页上下篇:publish_time 相同(如同秒批量导入)时以 id 决胜, + * 约定列表排序 = publish_time desc, id desc,上一篇文章是该序中的后继。 + */ + @Query("select a from CmsArticle a where a.siteId = :siteId and a.status = :status" + + " and (a.publishTime < :pt or (a.publishTime = :pt and a.id < :id))" + + " order by a.publishTime desc, a.id desc") + List findPrevPublished(@Param("siteId") Integer siteId, @Param("status") Integer status, + @Param("pt") LocalDateTime pt, @Param("id") Integer id, + Pageable pageable); + + @Query("select a from CmsArticle a where a.siteId = :siteId and a.status = :status" + + " and (a.publishTime > :pt or (a.publishTime = :pt and a.id > :id))" + + " order by a.publishTime asc, a.id asc") + List findNextPublished(@Param("siteId") Integer siteId, @Param("status") Integer status, + @Param("pt") LocalDateTime pt, @Param("id") Integer id, + Pageable pageable); + + /** 站内搜索(title/summary LIKE,MVP 不上 ES,方案 §2) */ + Page findBySiteIdAndStatusAndTitleContainingOrSiteIdAndStatusAndSummaryContaining( + Integer siteId, Integer status, String title, Integer siteId2, Integer status2, String summary, + Pageable pageable); + + /** 定时发布扫描:草稿且发布时间已到 */ + List findByStatusAndPublishTimeNotNullAndPublishTimeLessThanEqual(Integer status, + LocalDateTime now); + + /** 定时下架扫描:已发布且下架时间已到 */ + List findByStatusAndOfflineTimeNotNullAndOfflineTimeLessThanEqual(Integer status, + LocalDateTime now); + + /** 浏览量异步累加(fire-and-forget,失败忽略,方案 §5.2) */ + @Modifying + @Query("update CmsArticle a set a.viewCount = a.viewCount + 1 where a.id = :id") + int incrementViewCount(@Param("id") Integer id); +} diff --git a/src/main/java/com/yunhai/cms/repository/CmsAttachmentRepository.java b/src/main/java/com/yunhai/cms/repository/CmsAttachmentRepository.java new file mode 100644 index 0000000..53baedc --- /dev/null +++ b/src/main/java/com/yunhai/cms/repository/CmsAttachmentRepository.java @@ -0,0 +1,35 @@ +package com.yunhai.cms.repository; + +import com.yunhai.cms.entity.CmsAttachment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CmsAttachmentRepository extends JpaRepository { + + Page findBySiteId(Integer siteId, Pageable pageable); + + List findByArticleId(Integer articleId); + + /** 编辑器图片上传时先按路径去重绑定,避免重复行 */ + long countBySiteIdAndFilePath(Integer siteId, String filePath); + + /** 孤儿附件(文章已删除:article_id is null) */ + Page findBySiteIdAndArticleIdIsNull(Integer siteId, Pageable pageable); + + long countBySiteIdAndArticleIdIsNull(Integer siteId); + + @Modifying + @Query("update CmsAttachment a set a.articleId = :articleId where a.id in :ids") + int bindArticle(@Param("articleId") Integer articleId, @Param("ids") List ids); + + /** 文章删除:附件转孤儿 */ + @Modifying + @Query("update CmsAttachment a set a.articleId = null where a.articleId = :articleId") + int unbindArticle(@Param("articleId") Integer articleId); +} diff --git a/src/main/java/com/yunhai/cms/repository/CmsBannerRepository.java b/src/main/java/com/yunhai/cms/repository/CmsBannerRepository.java new file mode 100644 index 0000000..1240511 --- /dev/null +++ b/src/main/java/com/yunhai/cms/repository/CmsBannerRepository.java @@ -0,0 +1,17 @@ +package com.yunhai.cms.repository; + +import com.yunhai.cms.entity.CmsBanner; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CmsBannerRepository extends JpaRepository { + + /** 管理端:展示位全量(含停用,按排序) */ + List findBySiteIdAndPositionOrderBySortNoAscIdAsc(Integer siteId, String position); + + List findBySiteIdOrderByPositionAscSortNoAscIdAsc(Integer siteId); + + /** 引用完整性(删除拦截用):指定跳转类型下是否存在指向目标 id 的轮播(跨站也拦,link_value 全局比对) */ + boolean existsByLinkTypeAndLinkValue(Integer linkType, String linkValue); +} diff --git a/src/main/java/com/yunhai/cms/repository/CmsCategoryRepository.java b/src/main/java/com/yunhai/cms/repository/CmsCategoryRepository.java new file mode 100644 index 0000000..79dc669 --- /dev/null +++ b/src/main/java/com/yunhai/cms/repository/CmsCategoryRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.cms.repository; + +import com.yunhai.cms.entity.CmsCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CmsCategoryRepository extends JpaRepository { + + /** 栏目树数据源(内存组树,站点数 ≤20 栏目量级小) */ + List findBySiteIdOrderBySortNoAscIdAsc(Integer siteId); + + long countBySiteId(Integer siteId); + + long countByParentId(Integer parentId); +} diff --git a/src/main/java/com/yunhai/cms/repository/CmsLinkRepository.java b/src/main/java/com/yunhai/cms/repository/CmsLinkRepository.java new file mode 100644 index 0000000..ac4fd1d --- /dev/null +++ b/src/main/java/com/yunhai/cms/repository/CmsLinkRepository.java @@ -0,0 +1,11 @@ +package com.yunhai.cms.repository; + +import com.yunhai.cms.entity.CmsLink; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CmsLinkRepository extends JpaRepository { + + List findBySiteIdOrderByGroupNameAscSortNoAscIdAsc(Integer siteId); +} diff --git a/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java b/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java new file mode 100644 index 0000000..1b38741 --- /dev/null +++ b/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java @@ -0,0 +1,24 @@ +package com.yunhai.cms.repository; + +import com.yunhai.cms.entity.CmsSite; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CmsSiteRepository extends JpaRepository { + + Optional findByCode(String code); + + /** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */ + List findByStatusOrderByIdAsc(Integer status); + + /** 本校可管理的站点(非超管按 currentRole.schId 过滤) */ + List findBySchId(Integer schId); + + /** 指定主站下的启用院系子站(门户"专业院系"下拉数据源,《院系子站方案》) */ + List findByParentSiteIdAndStatusOrderByIdAsc(Integer parentSiteId, Integer status); + + /** 是否存在院系子站(站点改挂上级前的守卫) */ + boolean existsByParentSiteId(Integer parentSiteId); +} diff --git a/src/main/java/com/yunhai/cms/service/WeChatPushService.java b/src/main/java/com/yunhai/cms/service/WeChatPushService.java new file mode 100644 index 0000000..891c2e0 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/WeChatPushService.java @@ -0,0 +1,21 @@ +package com.yunhai.cms.service; + +import com.yunhai.cms.event.ArticlePublishedEvent; + +/** + * 微信图文推送服务接口(方案 §7.4 预留入口)。 + * + *

本期 {@link com.yunhai.cms.service.impl.NoopWeChatPushService} 空实现: + * 事件照常发布,Noop 直接返回,零微信外联/零微信配置键/零新增依赖。 + * 启用时按《微信平台对接方案.md》提供真实实现(公众号配置经 projman 注入)。 + */ +public interface WeChatPushService { + + /** + * 文章发布后回调(AFTER_COMMIT)。 + * + * @param event 发布事件(articleId/siteId/title) + * @return 是否已推送(Noop 恒 false) + */ + boolean onArticlePublished(ArticlePublishedEvent event); +} diff --git a/src/main/java/com/yunhai/cms/service/impl/ArticleScheduleTask.java b/src/main/java/com/yunhai/cms/service/impl/ArticleScheduleTask.java new file mode 100644 index 0000000..f6b5a74 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/ArticleScheduleTask.java @@ -0,0 +1,35 @@ +package com.yunhai.cms.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 定时发布/下架触发器(方案 §13 风险表:MVP 用 @Scheduled 每分钟扫到期记录,无重调度器)。 + * 扫描语义在 {@link ArticleScheduleWorker}(D5-5 拆分:触发与逻辑分离,IT 直接调 Worker + * 消除自动调度与手动调用的并发竞态)。cms.schedule.auto-enabled=false 可关自动触发 + * (仅集成测试用,生产默认开)。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "cms.schedule.auto-enabled", havingValue = "true", matchIfMissing = true) +public class ArticleScheduleTask { + + private final ArticleScheduleWorker worker; + + /** 定时发布:草稿且发布时间已到 */ + @Scheduled(initialDelay = 30_000, fixedDelay = 60_000) + public void publishDue() { + worker.publishDue(); + } + + /** 定时下架:已发布且下架时间已到 */ + @Scheduled(initialDelay = 45_000, fixedDelay = 60_000) + public void offlineDue() { + worker.offlineDue(); + } +} + diff --git a/src/main/java/com/yunhai/cms/service/impl/ArticleScheduleWorker.java b/src/main/java/com/yunhai/cms/service/impl/ArticleScheduleWorker.java new file mode 100644 index 0000000..d85c713 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/ArticleScheduleWorker.java @@ -0,0 +1,71 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.event.ArticleChangedEvent; +import com.yunhai.cms.event.ArticlePublishedEvent; +import com.yunhai.cms.repository.CmsArticleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 定时发布/下架扫描逻辑(方案 §13 风险表:MVP 每分钟扫到期记录,无重调度器)。 + * 从 ArticleScheduleTask 拆出纯逻辑体(D5-5):@Scheduled 触发器与扫描语义分离, + * 集成测试直接调 Worker 消除"上下文自动调度与测试手动调用并发处理同一记录"的竞态 + * (表现为事件数翻倍断言失败)。每条记录独立事务;到期即发对应事件 + * (缓存失效 + 微信挂点照常生效)。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ArticleScheduleWorker { + + private final CmsArticleRepository articleRepository; + private final TransactionTemplate transactionTemplate; + private final ApplicationEventPublisher eventPublisher; + + /** 定时发布:草稿且发布时间已到 */ + public void publishDue() { + List dueList = + articleRepository.findByStatusAndPublishTimeNotNullAndPublishTimeLessThanEqual( + ArticleStatusEnum.DRAFT.getCode(), LocalDateTime.now()); + for (CmsArticle due : dueList) { + transactionTemplate.executeWithoutResult(status -> { + CmsArticle article = articleRepository.findById(due.getId()).orElse(null); + if (article == null || article.getStatus() != ArticleStatusEnum.DRAFT.getCode()) { + return; + } + article.setStatus(ArticleStatusEnum.PUBLISHED.getCode()); + articleRepository.save(article); + eventPublisher.publishEvent(new ArticlePublishedEvent(article.getId(), + article.getSiteId(), article.getTitle())); + log.info("定时发布文章 articleId={} siteId={}", article.getId(), article.getSiteId()); + }); + } + } + + /** 定时下架:已发布且下架时间已到 */ + public void offlineDue() { + List dueList = + articleRepository.findByStatusAndOfflineTimeNotNullAndOfflineTimeLessThanEqual( + ArticleStatusEnum.PUBLISHED.getCode(), LocalDateTime.now()); + for (CmsArticle due : dueList) { + transactionTemplate.executeWithoutResult(status -> { + CmsArticle article = articleRepository.findById(due.getId()).orElse(null); + if (article == null || article.getStatus() != ArticleStatusEnum.PUBLISHED.getCode()) { + return; + } + article.setStatus(ArticleStatusEnum.OFFLINE.getCode()); + articleRepository.save(article); + eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), article.getSiteId())); + log.info("定时下架文章 articleId={} siteId={}", article.getId(), article.getSiteId()); + }); + } + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/ArticleService.java b/src/main/java/com/yunhai/cms/service/impl/ArticleService.java new file mode 100644 index 0000000..e354b3b --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/ArticleService.java @@ -0,0 +1,237 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.entity.CmsCategory; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.event.ArticleChangedEvent; +import com.yunhai.cms.event.ArticlePublishedEvent; +import com.yunhai.cms.model.dto.ArticleSaveRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.query.ArticlePageQuery; +import com.yunhai.cms.model.vo.ArticleEditVo; +import com.yunhai.cms.model.vo.ArticlePageItem; +import com.yunhai.cms.repository.CmsArticleRepository; +import com.yunhai.cms.repository.CmsAttachmentRepository; +import com.yunhai.cms.repository.CmsCategoryRepository; +import com.yunhai.cms.utils.HtmlSanitizer; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 文章管理(方案 §9.1/§9.2):草稿/发布/下架/置顶/定时发布下架 + jsoup 保存时清洗。 + * 发布动作发布 {@link ArticlePublishedEvent}(AFTER_COMMIT:缓存失效 + 微信 Noop 挂点)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ArticleService { + + private final CmsArticleRepository articleRepository; + private final CmsCategoryRepository categoryRepository; + private final CmsAttachmentRepository attachmentRepository; + private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository; + private final SiteService siteService; + private final ApplicationEventPublisher eventPublisher; + + /** 文章分页(管理端,全状态可见);pageSize 上限 100 防全表拖库(体检 H-6) */ + public Map page(BoaUser user, ArticlePageQuery query) { + siteService.requireManageable(user, query.getSiteId()); + int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1; + int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize(); + pageSize = Math.min(pageSize, 100); + Pageable pageable = PageRequest.of(pageIndex, pageSize, + Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("createTime"))); + + Specification spec = (root, cq, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("siteId"), query.getSiteId())); + if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) { + predicates.add(root.get("categoryId").in(query.getCategoryIds())); + } + if (query.getStatus() != null) { + predicates.add(cb.equal(root.get("status"), query.getStatus())); + } + if (StringUtils.hasText(query.getKeyword())) { + predicates.add(cb.like(root.get("title"), "%" + query.getKeyword().trim() + "%")); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + + Map categoryNames = categoryNameMap(query.getSiteId()); + Page page = articleRepository.findAll(spec, pageable); + List items = new ArrayList<>(); + for (CmsArticle article : page.getContent()) { + items.add(ArticlePageItem.of(article, categoryNames.get(article.getCategoryId()))); + } + Map result = new HashMap<>(); + result.put("total", page.getTotalElements()); + result.put("list", items); + return result; + } + + private Map categoryNameMap(Integer siteId) { + Map names = new HashMap<>(); + for (CmsCategory c : categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId)) { + names.put(c.getId(), c.getName()); + } + return names; + } + + /** 编辑回显 */ + public ArticleEditVo getEdit(BoaUser user, Integer siteId, Integer id) { + siteService.requireManageable(user, siteId); + CmsArticle article = articleRepository.findByIdAndSiteId(id, siteId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "文章不存在")); + return ArticleEditVo.of(article); + } + + /** + * 保存(草稿)。content 过 jsoup 白名单;摘要空则截取正文; + * 请求携带 attachmentIds 时回填编辑器图片归属。已发布文章被修改后发变更事件保持门户新鲜。 + */ + @Transactional + public Integer save(BoaUser user, ArticleSaveRequest req) { + CmsSite site = siteService.requireManageable(user, req.getSiteId()); + CmsCategory category = categoryRepository.findById(req.getCategoryId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "所属栏目不存在")); + if (!category.getSiteId().equals(site.getId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "栏目不属于该站点"); + } + + CmsArticle article; + if (req.getId() == null) { + article = new CmsArticle(); + article.setSiteId(site.getId()); + article.setStatus(ArticleStatusEnum.DRAFT.getCode()); + } else { + article = articleRepository.findByIdAndSiteId(req.getId(), site.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "文章不存在")); + } + validateTimeWindow(req.getPublishTime(), req.getOfflineTime()); + article.setCategoryId(category.getId()); + article.setTitle(req.getTitle()); + article.setContent(HtmlSanitizer.clean(req.getContent())); + String summary = req.getSummary(); + if (!StringUtils.hasText(summary)) { + summary = HtmlSanitizer.toText(article.getContent(), 120); + } + article.setSummary(summary); + article.setCoverImage(req.getCoverImage()); + article.setAuthorName(req.getAuthorName()); + article.setSource(req.getSource()); + article.setTopmost(req.getTopmost() == null ? 0 : req.getTopmost()); + article.setPublishTime(req.getPublishTime()); + article.setOfflineTime(req.getOfflineTime()); + article = articleRepository.save(article); + + if (req.getAttachmentIds() != null && !req.getAttachmentIds().isEmpty()) { + attachmentRepository.bindArticle(article.getId(), req.getAttachmentIds()); + } + eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId())); + return article.getId(); + } + + /** + * 发布:填发布时间与作者。publishTime 为空或已过 → 立即发布; + * 未来时间 → 保持草稿(定时发布,@Scheduled 扫描触发)。 + */ + @Transactional + public void publish(BoaUser user, Integer siteId, Integer id) { + CmsSite site = siteService.requireManageable(user, siteId); + CmsArticle article = requireArticle(site.getId(), id); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime effective = article.getPublishTime() != null && article.getPublishTime().isAfter(now) + ? article.getPublishTime() : now; + // 重新发布即开启新生命周期:过期的定时下架时间若不清掉,发布后会被调度器立刻下架 + // (旧数据/边界操作产生的矛盾态在此自愈,save 入口已拦截新产生的矛盾时间窗) + if (article.getOfflineTime() != null && !article.getOfflineTime().isAfter(effective)) { + article.setOfflineTime(null); + } + article.setPublishTime(effective); + if (!StringUtils.hasText(article.getAuthorAccount()) && user != null) { + article.setAuthorAccount(user.getAccount()); + if (!StringUtils.hasText(article.getAuthorName())) { + article.setAuthorName(user.getName()); + } + } + if (effective.isAfter(now)) { + article.setStatus(ArticleStatusEnum.DRAFT.getCode()); + articleRepository.save(article); + eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId())); + } else { + article.setStatus(ArticleStatusEnum.PUBLISHED.getCode()); + articleRepository.save(article); + eventPublisher.publishEvent(new ArticlePublishedEvent(article.getId(), site.getId(), article.getTitle())); + } + } + + /** 下架 */ + @Transactional + public void offline(BoaUser user, Integer siteId, Integer id) { + CmsSite site = siteService.requireManageable(user, siteId); + CmsArticle article = requireArticle(site.getId(), id); + article.setStatus(ArticleStatusEnum.OFFLINE.getCode()); + articleRepository.save(article); + eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId())); + } + + /** 置顶开关 */ + @Transactional + public void topmost(BoaUser user, Integer siteId, Integer id, boolean enable) { + CmsSite site = siteService.requireManageable(user, siteId); + CmsArticle article = requireArticle(site.getId(), id); + article.setTopmost(enable ? 1 : 0); + articleRepository.save(article); + eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId())); + } + + /** + * 删除:附件转孤儿(article_id 置空,由附件管理页清理,方案 §9.1 附件管理)。 + * 轮播仍引用该文章时拦截删除(引用完整性统一规则:被引用即拦,与栏目删除一致)。 + */ + @Transactional + public void delete(BoaUser user, DeleteRequest req) { + CmsSite site = siteService.requireManageable(user, req.getSiteId()); + CmsArticle article = requireArticle(site.getId(), req.getId()); + if (bannerRepository.existsByLinkTypeAndLinkValue( + com.yunhai.cms.enums.LinkTypeEnum.ARTICLE.getCode(), String.valueOf(article.getId()))) { + throw new BizException(ErrorCode.PARAM_ERROR, + "存在轮播图跳转到该文章,请先在轮播管理中删除或修改对应轮播"); + } + attachmentRepository.unbindArticle(article.getId()); + articleRepository.delete(article); + eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId())); + } + + /** 定时时间窗校验:offlineTime 必须晚于 publishTime(否则调度器会发布后立刻下架,状态机自相矛盾) */ + private void validateTimeWindow(LocalDateTime publishTime, LocalDateTime offlineTime) { + if (publishTime != null && offlineTime != null && !offlineTime.isAfter(publishTime)) { + throw new BizException(ErrorCode.PARAM_ERROR, "定时下架时间必须晚于发布时间"); + } + } + + private CmsArticle requireArticle(Integer siteId, Integer id) { + return articleRepository.findByIdAndSiteId(id, siteId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "文章不存在")); + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java b/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java new file mode 100644 index 0000000..8a587a2 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java @@ -0,0 +1,217 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsAttachment; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.vo.AttachmentVo; +import com.yunhai.cms.repository.CmsAttachmentRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +/** + * 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。 + * 存储 {FILE_PATH}/cms/{siteCode}/{yyyyMM}/{uuid32}.{ext};类型白名单 + 重命名存储, + * 禁止可执行后缀(方案 §8.5);图片类叠加 Content-Type + 魔数双校验(A-3)。 + * 轮播图/友链 Logo 走同一接口。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AttachmentService { + + private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM"); + + /** 上传白名单(图片/文档/压缩包/音视频);svg 因可携带脚本不入白名单(XSS) */ + private static final List ALLOWED_EXTS = Arrays.asList( + "jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", + "zip", "rar", "7z", "mp4", "mp3", "wav"); + + /** 图片类扩展名:在白名单之上叠加 Content-Type + 魔数双校验,防伪装后缀(A-3) */ + private static final List IMAGE_EXTS = Arrays.asList( + "jpg", "jpeg", "png", "gif", "webp", "bmp", "ico"); + + private final CmsAttachmentRepository attachmentRepository; + private final SiteService siteService; + + @Value("${FILE_PATH:./upload/}") + private String filePath; + + /** 只接受相对路径(/cms/upload/)或公网可达绝对地址;绝对内网地址(localhost:8072 等)入库即断图(B-1/C-5) */ + @Value("${FILE_WEB_PATH:/cms/upload/}") + private String fileWebPath; + + /** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空 */ + @Transactional + public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, MultipartFile file) { + CmsSite site = siteService.requireManageable(user, siteId); + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空"); + } + String originalName = file.getOriginalFilename() == null ? "file" : file.getOriginalFilename(); + String ext = extOf(originalName); + if (!ALLOWED_EXTS.contains(ext)) { + throw new BizException(ErrorCode.PARAM_ERROR, "不支持的文件类型:" + ext); + } + if (IMAGE_EXTS.contains(ext)) { + requireRealImage(file, ext); + } + + String relativeDir = "cms/" + site.getCode() + "/" + MONTH_DIR.format(LocalDateTime.now()); + String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext; + File dir = new File(filePath, relativeDir); + if (!dir.exists() && !dir.mkdirs()) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "上传目录创建失败"); + } + try { + file.transferTo(new File(dir, storedName)); + } catch (IOException e) { + log.error("附件保存失败 siteId={}", siteId, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "文件保存失败"); + } + + String relativePath = relativeDir + "/" + storedName; + String webUrl = joinUrl(fileWebPath, relativePath); + + CmsAttachment attachment = new CmsAttachment(); + attachment.setSiteId(site.getId()); + attachment.setArticleId(articleId); + attachment.setFileName(originalName); + attachment.setFilePath(relativePath); + attachment.setWebUrl(webUrl); + attachment.setFileSize(file.getSize()); + attachment.setMimeType(file.getContentType()); + attachment = attachmentRepository.save(attachment); + return AttachmentVo.of(attachment); + } + + /** 附件列表(orphanOnly=true 时仅孤儿,附件管理页清理用) */ + public Map page(BoaUser user, Integer siteId, boolean orphanOnly, + Integer page, Integer pageSize) { + siteService.requireManageable(user, siteId); + int pageIndex = page == null || page < 1 ? 0 : page - 1; + int size = pageSize == null || pageSize < 1 ? 10 : pageSize; + Page result = orphanOnly + ? attachmentRepository.findBySiteIdAndArticleIdIsNull(siteId, + PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime"))) + : attachmentRepository.findBySiteId(siteId, + PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime"))); + List list = new ArrayList<>(); + for (CmsAttachment a : result.getContent()) { + list.add(AttachmentVo.of(a)); + } + Map map = new HashMap<>(); + map.put("total", result.getTotalElements()); + map.put("list", list); + map.put("orphanCount", attachmentRepository.countBySiteIdAndArticleIdIsNull(siteId)); + return map; + } + + /** 删除附件记录(不删物理文件,运维定期清理磁盘,避免误删被引用文件) */ + @Transactional + public void delete(BoaUser user, DeleteRequest req) { + siteService.requireManageable(user, req.getSiteId()); + CmsAttachment attachment = attachmentRepository.findById(req.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "附件不存在")); + if (!attachment.getSiteId().equals(req.getSiteId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权删除该附件"); + } + attachmentRepository.delete(attachment); + } + + /** + * 图片类双校验(A-3):Content-Type 必须 image/*,且内容必须真实可解。 + * 防 .exe 改名 .jpg 之类伪装后缀入库后经门户以图片身份对外分发。 + */ + private void requireRealImage(MultipartFile file, String ext) { + String contentType = file.getContentType(); + if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) { + throw new BizException(ErrorCode.PARAM_ERROR, "图片文件类型非法(Content-Type=" + contentType + ")"); + } + if (!isDecodableImage(file, ext)) { + throw new BizException(ErrorCode.PARAM_ERROR, "文件内容不是有效图片(疑似伪装后缀):" + file.getOriginalFilename()); + } + } + + /** + * 魔数校验:jpg/jpeg/png/gif/bmp 用 ImageIO 真实解码(可读才算图片); + * webp/ico JDK8 无内置 reader,退化为魔数头校验(RIFF....WEBP / 00 00 01 00)。 + */ + private boolean isDecodableImage(MultipartFile file, String ext) { + if ("webp".equals(ext) || "ico".equals(ext)) { + byte[] head = readHead(file, 12); + if (head.length < 4) { + return false; + } + if ("webp".equals(ext)) { + return head.length >= 12 + && head[0] == 'R' && head[1] == 'I' && head[2] == 'F' && head[3] == 'F' + && head[8] == 'W' && head[9] == 'E' && head[10] == 'B' && head[11] == 'P'; + } + return head[0] == 0 && head[1] == 0 && head[2] == 1 && head[3] == 0; + } + try (InputStream in = file.getInputStream()) { + return ImageIO.read(in) != null; + } catch (IOException e) { + log.warn("图片魔数校验读取失败:{}", file.getOriginalFilename()); + return false; + } + } + + private byte[] readHead(MultipartFile file, int length) { + byte[] head = new byte[length]; + try (InputStream in = file.getInputStream()) { + int read = 0; + while (read < length) { + int n = in.read(head, read, length - read); + if (n < 0) { + break; + } + read += n; + } + return Arrays.copyOf(head, read); + } catch (IOException e) { + return new byte[0]; + } + } + + private String extOf(String name) { + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return ""; + } + return name.substring(dot + 1).toLowerCase(Locale.ROOT); + } + + private String joinUrl(String base, String relative) { + if (!StringUtils.hasText(base)) { + return "/" + relative; + } + return base.endsWith("/") ? base + relative : base + "/" + relative; + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/BannerService.java b/src/main/java/com/yunhai/cms/service/impl/BannerService.java new file mode 100644 index 0000000..b8a08d0 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/BannerService.java @@ -0,0 +1,130 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsBanner; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.enums.LinkTypeEnum; +import com.yunhai.cms.event.SiteContentChangedEvent; +import com.yunhai.cms.model.dto.BannerSaveRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.vo.BannerVo; +import com.yunhai.cms.repository.CmsArticleRepository; +import com.yunhai.cms.repository.CmsBannerRepository; +import com.yunhai.cms.repository.CmsCategoryRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * 轮播/推荐位管理(方案 §9.1 轮播管理)。保存时校验 link_type/link_value 匹配。 + */ +@Service +@RequiredArgsConstructor +public class BannerService { + + private final CmsBannerRepository bannerRepository; + private final CmsArticleRepository articleRepository; + private final CmsCategoryRepository categoryRepository; + private final SiteService siteService; + private final ApplicationEventPublisher eventPublisher; + + public List list(BoaUser user, Integer siteId, String position) { + siteService.requireManageable(user, siteId); + List banners = StringUtils.hasText(position) + ? bannerRepository.findBySiteIdAndPositionOrderBySortNoAscIdAsc(siteId, position) + : bannerRepository.findBySiteIdOrderByPositionAscSortNoAscIdAsc(siteId); + List result = new ArrayList<>(); + for (CmsBanner b : banners) { + result.add(BannerVo.of(b)); + } + return result; + } + + @Transactional + public BannerVo save(BoaUser user, BannerSaveRequest req) { + CmsSite site = siteService.requireManageable(user, req.getSiteId()); + LinkTypeEnum linkType = LinkTypeEnum.ofCode(req.getLinkType()); + if (linkType == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "跳转类型非法:" + req.getLinkType() + "(仅 1=文章 2=栏目 3=外链)"); + } + validateLink(site.getId(), linkType, req.getLinkValue()); + + CmsBanner banner; + if (req.getId() == null) { + banner = new CmsBanner(); + banner.setSiteId(site.getId()); + } else { + banner = bannerRepository.findById(req.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "轮播不存在")); + if (!banner.getSiteId().equals(site.getId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权修改该轮播"); + } + } + banner.setPosition(StringUtils.hasText(req.getPosition()) ? req.getPosition() : "home"); + banner.setTitle(req.getTitle()); + banner.setImageUrl(req.getImageUrl()); + banner.setLinkType(linkType.getCode()); + banner.setLinkValue(req.getLinkValue()); + banner.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo()); + banner.setStartTime(req.getStartTime()); + banner.setEndTime(req.getEndTime()); + if (banner.getStartTime() != null && banner.getEndTime() != null + && banner.getEndTime().isBefore(banner.getStartTime())) { + throw new BizException(ErrorCode.PARAM_ERROR, "下线时间不能早于上线时间"); + } + banner.setStatus(req.getStatus() == null ? 1 : req.getStatus()); + banner = bannerRepository.save(banner); + eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner")); + return BannerVo.of(banner); + } + + @Transactional + public void delete(BoaUser user, DeleteRequest req) { + CmsSite site = siteService.requireManageable(user, req.getSiteId()); + CmsBanner banner = bannerRepository.findById(req.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "轮播不存在")); + if (!banner.getSiteId().equals(site.getId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权删除该轮播"); + } + bannerRepository.delete(banner); + eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner")); + } + + /** + * link_type/link_value 匹配校验:1=文章 2=栏目(须存在且属于本站,禁止跨站引用) + * 3=外链(必须 http/https 开头)。 + */ + private void validateLink(Integer siteId, LinkTypeEnum linkType, String linkValue) { + if (linkType == LinkTypeEnum.EXTERNAL) { + if (!StringUtils.hasText(linkValue) + || !(linkValue.startsWith("http://") || linkValue.startsWith("https://"))) { + throw new BizException(ErrorCode.PARAM_ERROR, "外链地址必须以 http(s):// 开头"); + } + return; + } + if (!StringUtils.hasText(linkValue)) { + throw new BizException(ErrorCode.PARAM_ERROR, "跳转目标不能为空"); + } + Integer targetId; + try { + targetId = Integer.valueOf(linkValue.trim()); + } catch (NumberFormatException e) { + throw new BizException(ErrorCode.PARAM_ERROR, "跳转目标必须是数字 ID"); + } + if (linkType == LinkTypeEnum.ARTICLE) { + articleRepository.findByIdAndSiteId(targetId, siteId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "跳转文章不存在")); + } else if (linkType == LinkTypeEnum.CATEGORY) { + categoryRepository.findById(targetId) + .filter(c -> c.getSiteId().equals(siteId)) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "跳转栏目不存在")); + } + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/CategoryService.java b/src/main/java/com/yunhai/cms/service/impl/CategoryService.java new file mode 100644 index 0000000..c91704a --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/CategoryService.java @@ -0,0 +1,173 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsCategory; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.event.SiteContentChangedEvent; +import com.yunhai.cms.model.dto.CategorySaveRequest; +import com.yunhai.cms.model.dto.CategorySortRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.vo.CategoryTreeNode; +import com.yunhai.cms.repository.CmsArticleRepository; +import com.yunhai.cms.repository.CmsCategoryRepository; +import com.yunhai.cms.utils.HtmlSanitizer; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 栏目树管理(方案 §9.1 栏目管理)。删除校验空栏目(无子栏目且无文章)。 + */ +@Service +@RequiredArgsConstructor +public class CategoryService { + + private final CmsCategoryRepository categoryRepository; + private final CmsArticleRepository articleRepository; + private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository; + private final SiteService siteService; + private final ApplicationEventPublisher eventPublisher; + + /** 栏目树(管理端全量含隐藏;门户侧由 PortalQueryService 另行过滤) */ + public List tree(BoaUser user, Integer siteId) { + siteService.requireManageable(user, siteId); + List all = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId); + Map nodeMap = new HashMap<>(); + for (CmsCategory c : all) { + nodeMap.put(c.getId(), CategoryTreeNode.of(c)); + } + List roots = new ArrayList<>(); + for (CmsCategory c : all) { + CategoryTreeNode node = nodeMap.get(c.getId()); + CategoryTreeNode parent = c.getParentId() == null ? null : nodeMap.get(c.getParentId()); + if (parent == null) { + roots.add(node); + } else { + parent.getChildren().add(node); + } + } + return roots; + } + + @Transactional + public CategoryTreeNode save(BoaUser user, CategorySaveRequest req) { + CmsSite site = siteService.requireManageable(user, req.getSiteId()); + CategoryTypeEnum type = CategoryTypeEnum.ofCode(req.getType()); + if (type == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "栏目类型非法:" + req.getType() + "(仅 1=列表 2=单页 3=外链)"); + } + if (type == CategoryTypeEnum.LINK && !StringUtils.hasText(req.getLinkUrl())) { + throw new BizException(ErrorCode.PARAM_ERROR, "外链栏目必须填写外链地址"); + } + CmsCategory category; + if (req.getId() == null) { + category = new CmsCategory(); + category.setSiteId(site.getId()); + } else { + category = categoryRepository.findById(req.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "栏目不存在")); + if (!category.getSiteId().equals(site.getId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权修改该栏目"); + } + } + validateParent(category, req); + category.setParentId(req.getParentId() == null ? 0 : req.getParentId()); + category.setName(req.getName()); + category.setType(type.getCode()); + category.setLinkUrl(req.getLinkUrl()); + category.setTemplate(req.getTemplate()); + category.setContent(HtmlSanitizer.clean(req.getContent())); + category.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo()); + category.setSeoTitle(req.getSeoTitle()); + category.setSeoDescription(req.getSeoDescription()); + category.setStatus(req.getStatus() == null ? 1 : req.getStatus()); + category = categoryRepository.save(category); + eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "category")); + return CategoryTreeNode.of(category); + } + + private void validateParent(CmsCategory category, CategorySaveRequest req) { + Integer parentId = req.getParentId() == null ? 0 : req.getParentId(); + if (parentId == 0) { + return; + } + if (category.getId() != null && parentId.equals(category.getId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不能是自身"); + } + CmsCategory parent = categoryRepository.findById(parentId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "父栏目不存在")); + if (!parent.getSiteId().equals(req.getSiteId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不属于该站点"); + } + if (category.getId() != null && parent.getParentId() != null + && parent.getParentId().equals(category.getId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "不支持把子栏目设为父栏目(成环)"); + } + } + + /** 删除(校验空栏目 + 轮播引用,统一"被引用即拦"规则) */ + @Transactional + public void delete(BoaUser user, DeleteRequest req) { + CmsSite site = siteService.requireManageable(user, req.getSiteId()); + CmsCategory category = categoryRepository.findById(req.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "栏目不存在")); + if (!category.getSiteId().equals(site.getId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权删除该栏目"); + } + if (categoryRepository.countByParentId(category.getId()) > 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "存在子栏目,请先删除子栏目"); + } + if (articleRepository.countByCategoryId(category.getId()) > 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "栏目下存在文章,请先处理文章"); + } + if (bannerRepository.existsByLinkTypeAndLinkValue( + com.yunhai.cms.enums.LinkTypeEnum.CATEGORY.getCode(), String.valueOf(category.getId()))) { + throw new BizException(ErrorCode.PARAM_ERROR, + "存在轮播图跳转到该栏目,请先在轮播管理中删除或修改对应轮播"); + } + categoryRepository.delete(category); + eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "category")); + } + + /** + * 子栏目排序(UserBase SortChildren 同语义):ids 顺序即 sortNo 1..n。 + */ + @Transactional + public void sort(BoaUser user, CategorySortRequest req) { + CmsSite site = siteService.requireManageable(user, req.getSiteId()); + List ids = req.getIds(); + if (ids.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "子栏目ID列表不能为空"); + } + if (ids.stream().distinct().count() != ids.size()) { + throw new BizException(ErrorCode.PARAM_ERROR, "子栏目ID列表存在重复"); + } + List children = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(site.getId()).stream() + .filter(c -> c.getParentId() != null && c.getParentId().equals(req.getParentId())) + .collect(Collectors.toList()); + Map childMap = children.stream() + .collect(Collectors.toMap(CmsCategory::getId, c -> c, (a, b) -> a)); + for (Integer id : ids) { + if (!childMap.containsKey(id)) { + throw new BizException(ErrorCode.PARAM_ERROR, "ids 中存在不属于该栏目的直接子栏目"); + } + } + // 按传入顺序落库排序(1 起) + for (int i = 0; i < ids.size(); i++) { + childMap.get(ids.get(i)).setSortNo(i + 1); + } + categoryRepository.saveAll(children); + eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "category")); + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/CmsUserCenterService.java b/src/main/java/com/yunhai/cms/service/impl/CmsUserCenterService.java new file mode 100644 index 0000000..7f0b914 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/CmsUserCenterService.java @@ -0,0 +1,84 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaModule; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 用户中心代理(Notice UserCenterService 同款逻辑平移): + * 模块菜单树来自基础平台 SDK(YschBaseOpenApi.getUserModule),扁平转树对齐前端 layout 契约。 + */ +@Slf4j +@Service +public class CmsUserCenterService { + + /** + * 当前用户已授权模块树(左侧菜单)。 + */ + public List> getUserModuleTree(Integer userId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空"); + } + List modules; + try { + modules = YschBaseOpenApi.getUserModule(userId, schId); + } catch (Exception e) { + // 平台不可达时菜单降级为空(登录/站点能力不受影响),避免整页不可用 + log.warn("[cms] 获取用户模块失败, schId={}: {}", schId, e.getMessage()); + return Collections.emptyList(); + } + if (modules == null || modules.isEmpty()) { + return Collections.emptyList(); + } + return buildModuleTree(modules); + } + + /** + * 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望。 + * 顶层取 parentId=0 或父节点不在列表中的节点,保持 SDK 返回顺序。 + */ + private List> buildModuleTree(List modules) { + Map moduleMap = modules.stream() + .collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a)); + + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", m.getId()); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", new ArrayList<>()); + nodeMap.put(m.getId(), node); + } + + List> roots = new ArrayList<>(); + for (BoaModule m : modules) { + Map node = nodeMap.get(m.getId()); + int parentId = m.getParentId() != null ? m.getParentId() : 0; + if (parentId == 0 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + return roots; + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/LinkService.java b/src/main/java/com/yunhai/cms/service/impl/LinkService.java new file mode 100644 index 0000000..f21f6d8 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/LinkService.java @@ -0,0 +1,84 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsLink; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.event.SiteContentChangedEvent; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.dto.LinkSaveRequest; +import com.yunhai.cms.model.vo.LinkVo; +import com.yunhai.cms.repository.CmsLinkRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * 友情链接管理(方案 §9.1 友情链接管理)。 + */ +@Service +@RequiredArgsConstructor +public class LinkService { + + private final CmsLinkRepository linkRepository; + private final SiteService siteService; + private final ApplicationEventPublisher eventPublisher; + + public List list(BoaUser user, Integer siteId) { + siteService.requireManageable(user, siteId); + List links = linkRepository.findBySiteIdOrderByGroupNameAscSortNoAscIdAsc(siteId); + List result = new ArrayList<>(); + for (CmsLink l : links) { + result.add(LinkVo.of(l)); + } + return result; + } + + @Transactional + public LinkVo save(BoaUser user, LinkSaveRequest req) { + CmsSite site = siteService.requireManageable(user, req.getSiteId()); + if (!StringUtils.hasText(req.getLinkUrl()) + || !(req.getLinkUrl().startsWith("http://") || req.getLinkUrl().startsWith("https://") + || req.getLinkUrl().startsWith("/"))) { + throw new BizException(ErrorCode.PARAM_ERROR, "链接地址必须以 http(s):// 或 / 开头"); + } + CmsLink link; + if (req.getId() == null) { + link = new CmsLink(); + link.setSiteId(site.getId()); + } else { + link = linkRepository.findById(req.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "友链不存在")); + if (!link.getSiteId().equals(site.getId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权修改该友链"); + } + } + link.setGroupName(StringUtils.hasText(req.getGroupName()) ? req.getGroupName() : "default"); + link.setName(req.getName()); + link.setLogoUrl(req.getLogoUrl()); + link.setLinkUrl(req.getLinkUrl()); + link.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo()); + link.setStatus(req.getStatus() == null ? 1 : req.getStatus()); + link = linkRepository.save(link); + eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "link")); + return LinkVo.of(link); + } + + @Transactional + public void delete(BoaUser user, DeleteRequest req) { + CmsSite site = siteService.requireManageable(user, req.getSiteId()); + CmsLink link = linkRepository.findById(req.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "友链不存在")); + if (!link.getSiteId().equals(site.getId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权删除该友链"); + } + linkRepository.delete(link); + eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "link")); + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/NoopWeChatPushService.java b/src/main/java/com/yunhai/cms/service/impl/NoopWeChatPushService.java new file mode 100644 index 0000000..71b7a31 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/NoopWeChatPushService.java @@ -0,0 +1,22 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.event.ArticlePublishedEvent; +import com.yunhai.cms.service.WeChatPushService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 微信推送空实现(方案 §7.4):仅日志留痕,不产生任何外联请求。 + * 真实实现启用前,管理端"同步到公众号"开关保持灰显。 + */ +@Slf4j +@Service +public class NoopWeChatPushService implements WeChatPushService { + + @Override + public boolean onArticlePublished(ArticlePublishedEvent event) { + log.debug("微信推送未启用,忽略文章发布事件:articleId={}, title={}(规划见《微信平台对接方案.md》)", + event.getArticleId(), event.getTitle()); + return false; + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java new file mode 100644 index 0000000..716b51e --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java @@ -0,0 +1,251 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.config.CacheNames; +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.entity.CmsBanner; +import com.yunhai.cms.entity.CmsCategory; +import com.yunhai.cms.entity.CmsLink; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.model.portal.PortalArticleDetail; +import com.yunhai.cms.model.portal.PortalBanner; +import com.yunhai.cms.model.portal.PortalCategory; +import com.yunhai.cms.model.portal.PortalLinkGroup; +import com.yunhai.cms.model.portal.PortalListData; +import com.yunhai.cms.model.portal.PortalSiteBrief; +import com.yunhai.cms.model.portal.PortalSiteSnapshot; +import com.yunhai.cms.repository.CmsArticleRepository; +import com.yunhai.cms.repository.CmsBannerRepository; +import com.yunhai.cms.repository.CmsCategoryRepository; +import com.yunhai.cms.repository.CmsLinkRepository; +import com.yunhai.cms.repository.CmsSiteRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 门户只读查询(缓存入口,方案 §8.4)。 + * 缓存体为不可变 POJO 快照;失效由 AFTER_COMMIT 事件监听(PortalCacheEvictListener)。 + * 皮肤包不存在时回退 default,避免缺模板 500。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PortalQueryService { + + private final CmsSiteRepository siteRepository; + private final CmsCategoryRepository categoryRepository; + private final CmsArticleRepository articleRepository; + private final CmsBannerRepository bannerRepository; + private final CmsLinkRepository linkRepository; + private final com.yunhai.cms.theme.ThemeRegistry themeRegistry; + + /** 站点快照(siteCode → 站点+栏目树+轮播+友链);站点不存在/停用缓存 null 哨兵 */ + @Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode") + public PortalSiteSnapshot siteSnapshot(String siteCode) { + CmsSite site = siteRepository.findByCode(siteCode).orElse(null); + if (site == null || site.getStatus() == null || site.getStatus() != 1) { + return null; + } + PortalSiteSnapshot snapshot = PortalSiteSnapshot.of(site); + snapshot.setTheme(resolveTheme(snapshot.getTheme())); + // 微调参数注入(方案 §5.3):按已解析主题 schema 过滤,回退主题与参数过滤天然一致 + com.yunhai.cms.theme.ThemeInfo themeInfo = themeRegistry.get(snapshot.getTheme()); + java.util.Map themeParams = + themeInfo.filterValidValues(rawParamsMap(snapshot)); + snapshot.setThemeParams(themeParams); + snapshot.setThemeVarsCss(com.yunhai.cms.theme.ThemeVarsCss.build(themeInfo, themeParams)); + + // 栏目树(仅启用):一次查询复用两遍(建节点 + 组树),省一次 DB 往返 + List categories = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(site.getId()); + Map nodeMap = new HashMap<>(); + List roots = new ArrayList<>(); + for (CmsCategory c : categories) { + if (c.getStatus() == null || c.getStatus() != 1) { + continue; + } + PortalCategory node = PortalCategory.of(c); + nodeMap.put(node.getId(), node); + } + for (CmsCategory c : categories) { + PortalCategory node = nodeMap.get(c.getId()); + if (node == null) { + continue; + } + PortalCategory parent = c.getParentId() == null ? null : nodeMap.get(c.getParentId()); + if (parent == null) { + roots.add(node); + } else { + parent.getChildren().add(node); + } + } + snapshot.setCategories(roots); + + // 轮播(仅启用;时间窗过滤留给渲染) + List banners = new ArrayList<>(); + for (CmsBanner b : bannerRepository.findBySiteIdOrderByPositionAscSortNoAscIdAsc(site.getId())) { + if (b.getStatus() != null && b.getStatus() == 1) { + banners.add(PortalBanner.of(b)); + } + } + snapshot.setBanners(banners); + + // 友链分组(仅启用) + Map groupMap = new HashMap<>(); + List groups = new ArrayList<>(); + for (CmsLink l : linkRepository.findBySiteIdOrderByGroupNameAscSortNoAscIdAsc(site.getId())) { + if (l.getStatus() == null || l.getStatus() != 1) { + continue; + } + PortalLinkGroup group = groupMap.computeIfAbsent(l.getGroupName(), k -> { + PortalLinkGroup g = new PortalLinkGroup(); + g.setGroupName(k); + groups.add(g); + return g; + }); + PortalLinkGroup.PortalLink link = new PortalLinkGroup.PortalLink(); + link.setName(l.getName()); + link.setLogoUrl(l.getLogoUrl()); + link.setLinkUrl(l.getLinkUrl()); + group.getLinks().add(link); + } + snapshot.setLinkGroups(groups); + + // 同校院系子站("专业院系"下拉,《院系子站方案》):顶层站取其子站, + // 院系子站取兄弟站(含自身,对齐真实高校门户);跨校防御过滤 + Integer anchorId = snapshot.getParentSiteId() == 0 ? site.getId() : snapshot.getParentSiteId(); + List colleges = new ArrayList<>(); + for (CmsSite child : siteRepository.findByParentSiteIdAndStatusOrderByIdAsc(anchorId, 1)) { + if (site.getSchId() != null && !site.getSchId().equals(child.getSchId())) { + continue; + } + colleges.add(PortalSiteBrief.of(child)); + } + snapshot.setColleges(colleges); + return snapshot; + } + + /** 文章详情(已发布才可读;不存在/未发布缓存 null 哨兵 30s 防穿透) */ + @Cacheable(cacheNames = CacheNames.ARTICLE, key = "#articleId") + public PortalArticleDetail articleDetail(Integer articleId) { + CmsArticle article = articleRepository.findById(articleId).orElse(null); + if (article == null || article.getStatus() == null + || article.getStatus() != ArticleStatusEnum.PUBLISHED.getCode()) { + return null; + } + return PortalArticleDetail.of(article); + } + + /** + * 栏目分页(已发布,置顶优先 + 发布时间倒序;key=categoryId:page)。 + * D1-3:父栏目聚合其列表型子孙栏目的文章(高校惯例:文章挂叶子栏目,父栏目页展示汇总)。 + */ + @Cacheable(cacheNames = CacheNames.LIST, key = "#categoryId + ':' + #page") + public PortalListData categoryPage(Integer categoryId, Integer page, Integer pageSize) { + CmsCategory category = categoryRepository.findById(categoryId).orElse(null); + if (category == null) { + return null; + } + int pageIndex = page == null || page < 1 ? 0 : page - 1; + Pageable pageable = PageRequest.of(pageIndex, pageSize == null || pageSize < 1 ? 10 : pageSize, + Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))); + Page result = articleRepository.findBySiteIdAndCategoryIdInAndStatus( + category.getSiteId(), listCategoryIdsAggregatingChildren(category), + ArticleStatusEnum.PUBLISHED.getCode(), pageable); + + PortalListData data = new PortalListData(); + data.setCategoryId(categoryId); + data.setSiteId(category.getSiteId()); + data.setTotal(result.getTotalElements()); + data.setPage(pageIndex + 1); + data.setPageSize(pageable.getPageSize()); + data.setTotalPages(result.getTotalPages()); + for (CmsArticle a : result.getContent()) { + PortalListData.Item item = new PortalListData.Item(); + item.setId(a.getId()); + item.setTitle(a.getTitle()); + item.setSummary(a.getSummary()); + item.setCoverImage(a.getCoverImage()); + item.setPublishTime(a.getPublishTime()); + item.setTopmost(a.getTopmost()); + data.getItems().add(item); + } + return data; + } + + /** + * 栏目自身 + 其列表型子孙栏目 id 集(D1-3 聚合用)。 + * 一次性载入站点栏目(量级小),从目标栏目 BFS;单页/外链型子孙不纳入 + * (其文章不属于列表语义,且外链栏目无文章)。 + */ + private List listCategoryIdsAggregatingChildren(CmsCategory category) { + List ids = new ArrayList<>(); + ids.add(category.getId()); + Map> childrenMap = new HashMap<>(); + for (CmsCategory c : categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(category.getSiteId())) { + if (c.getParentId() != null) { + childrenMap.computeIfAbsent(c.getParentId(), k -> new ArrayList<>()).add(c); + } + } + java.util.ArrayDeque queue = new java.util.ArrayDeque<>(ids); + while (!queue.isEmpty()) { + Integer current = queue.poll(); + for (CmsCategory child : childrenMap.getOrDefault(current, java.util.Collections.emptyList())) { + if (child.getType() != null + && child.getType() == com.yunhai.cms.enums.CategoryTypeEnum.LIST.getCode()) { + ids.add(child.getId()); + queue.add(child.getId()); + } + } + } + return ids; + } + + /** 站内搜索(title/summary LIKE,实时不缓存,MVP 不上 ES,方案 §2) */ + public Page search(Integer siteId, String keyword, Integer page, Integer pageSize) { + int pageIndex = page == null || page < 1 ? 0 : page - 1; + Pageable pageable = PageRequest.of(pageIndex, pageSize == null || pageSize < 1 ? 10 : pageSize, + Sort.by(Sort.Order.desc("publishTime"))); + // Containing 自带 %包裹与转义,勿手动再加 %(否则字面 % 参与匹配导致恒不命中) + String like = keyword == null ? "" : keyword.trim(); + Page result = articleRepository + .findBySiteIdAndStatusAndTitleContainingOrSiteIdAndStatusAndSummaryContaining( + siteId, ArticleStatusEnum.PUBLISHED.getCode(), like, + siteId, ArticleStatusEnum.PUBLISHED.getCode(), like, pageable); + return result.map(a -> { + PortalListData.Item item = new PortalListData.Item(); + item.setId(a.getId()); + item.setTitle(a.getTitle()); + item.setSummary(a.getSummary()); + item.setCoverImage(a.getCoverImage()); + item.setPublishTime(a.getPublishTime()); + item.setTopmost(a.getTopmost()); + return item; + }); + } + + /** 皮肤包存在性检查:委托 ThemeRegistry(theme.json + 4 模板齐全校验,方案 §5.1 重构,缺省回退 default) */ + private String resolveTheme(String theme) { + if (themeRegistry.contains(theme)) { + return theme; + } + log.warn("皮肤包 {} 未注册,回退 default", theme); + return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME; + } + + /** config_json.theme_params 原始值(非对象形态按无参数处理) */ + private Map rawParamsMap(PortalSiteSnapshot snapshot) { + Object raw = snapshot.getConfig() == null ? null : snapshot.getConfig().get("theme_params"); + return raw instanceof Map ? (Map) raw : null; + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/SchoolService.java b/src/main/java/com/yunhai/cms/service/impl/SchoolService.java new file mode 100644 index 0000000..72968ea --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/SchoolService.java @@ -0,0 +1,118 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.model.vo.SchoolVo; +import com.yunhai.cms.model.vo.SiteVo; +import com.yunhai.cms.repository.CmsArticleRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 学校工作台(docs/09 §3.3,方案A 双层壳): + * ① listFor —— 学校卡片 = 顶层站点(主站),院系子站经 parentSiteId 归属; + * 权限语义与 {@link SiteService#listFor} 完全一致(超管全部 / 校级本校 / 无角色空)。 + * ② schoolOptions —— 「添加学校」选择器:代理 UserBase oapi getGroupTree 过滤 type=2(学校组), + * 只绑定不跨系统写(D6-4);平台不可达降级空数组(CmsUserCenterService 同款)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SchoolService { + + /** UserBase GroupTypeEnum.SCHOOL_GROUP:学校组节点类型 */ + private static final int GROUP_TYPE_SCHOOL = 2; + + private final SiteService siteService; + private final CmsArticleRepository articleRepository; + + /** 学校工作台列表:当前用户可见的顶层站点逐站聚合成学校卡片 */ + public List listFor(BoaUser user) { + List sites = siteService.listFor(user); + List result = new ArrayList<>(); + for (SiteVo top : sites) { + if (top.getParentSiteId() != null && top.getParentSiteId() > 0) { + continue; + } + SchoolVo vo = new SchoolVo(); + vo.setSchId(top.getSchId()); + vo.setSchName(top.getSchName()); + vo.setMainSiteId(top.getId()); + vo.setMainSiteCode(top.getCode()); + vo.setMainSiteName(top.getName()); + vo.setTheme(top.getTheme()); + vo.setStatus(top.getStatus()); + // 院系子站按 parentSiteId 归属(同权限范围内统计) + long articleCount = 0; + int siteCount = 1; + for (SiteVo s : sites) { + if (s.getParentSiteId() != null && top.getId().equals(s.getParentSiteId())) { + siteCount++; + } + } + for (SiteVo s : sites) { + boolean own = s.getId().equals(top.getId()); + boolean child = s.getParentSiteId() != null && top.getId().equals(s.getParentSiteId()); + if (own || child) { + articleCount += articleRepository.countBySiteId(s.getId()); + } + } + vo.setSiteCount(siteCount); + vo.setArticleCount(articleCount); + result.add(vo); + } + result.sort((a, b) -> Integer.compare(a.getMainSiteId(), b.getMainSiteId())); + return result; + } + + /** UserBase 学校选项(仅超管;添加学校=绑定已有学校,不新建) */ + public List schoolOptions(BoaUser user) { + siteService.requireSuperAdmin(user); + BoaGroup root; + try { + root = YschBaseOpenApi.getGroupTree(); + } catch (Exception e) { + log.warn("[cms] 获取 UserBase 学校组树失败,学校选项降级为空: {}", e.getMessage()); + return Collections.emptyList(); + } + List options = new ArrayList<>(); + collectSchools(root, options); + return options; + } + + private void collectSchools(BoaGroup node, List out) { + if (node == null) { + return; + } + if (node.getType() != null && node.getType() == GROUP_TYPE_SCHOOL) { + out.add(new SchoolVo.Option(node.getId(), node.getName())); + } + if (node.getChildren() != null) { + for (BoaGroup child : node.getChildren()) { + collectSchools(child, out); + } + } + } + + /** 工作台卡片挂载的站点摘要(供校名下拉/快速切换;权限复用 listFor) */ + public Map> sitesGroupedByMain(BoaUser user) { + List sites = siteService.listFor(user); + Map> grouped = new LinkedHashMap<>(); + for (SiteVo s : sites) { + Integer mainId = (s.getParentSiteId() == null || s.getParentSiteId() <= 0) + ? s.getId() : s.getParentSiteId(); + grouped.computeIfAbsent(mainId, k -> new ArrayList<>()).add(s); + } + return grouped; + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/SiteService.java b/src/main/java/com/yunhai/cms/service/impl/SiteService.java new file mode 100644 index 0000000..6f1ebf2 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/SiteService.java @@ -0,0 +1,267 @@ +package com.yunhai.cms.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.cms.config.CmsAuthProperties; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.event.SiteContentChangedEvent; +import com.yunhai.cms.model.dto.SiteSaveRequest; +import com.yunhai.cms.model.vo.SiteVo; +import com.yunhai.cms.repository.CmsSiteRepository; +import com.yunhai.cms.utils.ExtJsonUtils; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 站点与校级权限(方案 §7.2): + * ① 平台超管(BoaUser.type==1)→ 所有站点;② 学校角色(currentRole.schId)→ 仅本校站点(site.sch_id 匹配)。 + * 所有管理端写操作入口先过 {@link #requireManageable}。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SiteService { + + private final CmsSiteRepository siteRepository; + private final CmsAuthProperties authProperties; + private final ApplicationEventPublisher eventPublisher; + private final com.yunhai.cms.theme.ThemeRegistry themeRegistry; + + /** config_json 已知键约束(与前端 utils/siteConfigForm.ts 保持一致,docs/09 §0 键清单) */ + private static final ObjectMapper CONFIG_MAPPER = new ObjectMapper(); + private static final Pattern HOST_PATTERN = Pattern.compile( + "^(\\*\\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://\\S+$"); + private static final Pattern ASSET_PATTERN = Pattern.compile("^(/.+|https?://\\S+)$"); + private static final Pattern CONFIG_KEY_PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$"); + private static final Set RESERVED_CONFIG_KEYS = + java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params"))); + private static final int CONFIG_VALUE_MAX_LEN = 500; + + /** 当前用户可管理的站点列表(含上级站点名回填,展示"主站/院系子站"层级) */ + public List listFor(BoaUser user) { + List sites = authProperties.isSuperAdmin(user) + ? siteRepository.findAll() + : (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null + ? java.util.Collections.emptyList() + : siteRepository.findBySchId(user.getCurrentRole().getSchId())); + Map nameById = new java.util.HashMap<>(); + for (CmsSite site : sites) { + nameById.put(site.getId(), site.getName()); + } + List result = new ArrayList<>(); + for (CmsSite site : sites) { + SiteVo vo = SiteVo.of(site); + if (vo.getParentSiteId() != null && vo.getParentSiteId() > 0) { + vo.setParentName(nameById.get(vo.getParentSiteId())); + } + result.add(vo); + } + return result; + } + + /** 校验可管理权限并返回站点;越权抛 FORBIDDEN */ + public CmsSite requireManageable(BoaUser user, Integer siteId) { + CmsSite site = siteRepository.findById(siteId == null ? -1 : siteId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在")); + if (!canManage(user, site)) { + throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点"); + } + return site; + } + + public boolean canManage(BoaUser user, CmsSite site) { + if (authProperties.isSuperAdmin(user)) { + return true; + } + Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId(); + return schId != null && site.getSchId() != null && schId.equals(site.getSchId()); + } + + public void requireSuperAdmin(BoaUser user) { + if (!authProperties.isSuperAdmin(user)) { + throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可执行该操作"); + } + } + + public CmsSite getByCode(String code) { + return siteRepository.findByCode(code).orElse(null); + } + + public String configValue(CmsSite site, String key) { + Map config = ExtJsonUtils.parse(site.getConfigJson()); + return ExtJsonUtils.getString(config, key); + } + + /** 站点保存(仅超管;MVP 1-2 校以初始化脚本为主,本接口为后台兜底) */ + @Transactional + public SiteVo save(BoaUser user, SiteSaveRequest req) { + requireSuperAdmin(user); + CmsSite site; + if (req.getId() == null) { + if (siteRepository.findByCode(req.getCode()).isPresent()) { + throw new BizException(ErrorCode.PARAM_ERROR, "站点编码已存在"); + } + site = new CmsSite(); + site.setCode(req.getCode()); + } else { + site = siteRepository.findById(req.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在")); + } + validateParent(req, site); + // B-8:顶层站 schId 必填——canManage 按 currentRole.schId ↔ site.schId 匹配, + // 顶层站空 schId 建成即"校级角色不可见/不可管"(院系子站已在 validateParent 继承主站 schId,不受影响) + if ((site.getParentSiteId() == null || site.getParentSiteId() == 0) && req.getSchId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "顶层站点必须指定学校ID(schId),否则校级角色无法管理"); + } + site.setSchId(req.getSchId()); + site.setSchName(req.getSchName()); + site.setName(req.getName()); + String resolvedTheme = StringUtils.hasText(req.getTheme()) + ? req.getTheme() : com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME; + if (!themeRegistry.contains(resolvedTheme)) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme); + } + site.setTheme(resolvedTheme); + validateConfigJson(req.getConfigJson()); + site.setConfigJson(mergeThemeParams(req, resolvedTheme)); + site.setStatus(req.getStatus() == null ? 1 : req.getStatus()); + site = siteRepository.save(site); + eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "site")); + return SiteVo.of(site); + } + + /** + * themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键, + * 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。 + * SPA 不再手拼 configJson 中的该节点。 + */ + private String mergeThemeParams(SiteSaveRequest req, String theme) { + if (req.getThemeParams() == null) { + return req.getConfigJson(); + } + Map config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson())); + Map filtered = themeRegistry.get(theme).filterValidValues(req.getThemeParams()); + if (filtered.isEmpty()) { + config.remove("theme_params"); + } else { + config.put("theme_params", filtered); + } + try { + return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(config); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "站点配置序列化失败"); + } + } + + /** + * 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、 + * 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。 + */ + private void validateParent(SiteSaveRequest req, CmsSite site) { + Integer parentSiteId = req.getParentSiteId() == null ? 0 : req.getParentSiteId(); + if (parentSiteId <= 0) { + site.setParentSiteId(0); + return; + } + if (site.getId() != null && site.getId().equals(parentSiteId)) { + throw new BizException(ErrorCode.PARAM_ERROR, "上级站点不能是自身"); + } + CmsSite parent = siteRepository.findById(parentSiteId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "上级站点不存在")); + if (parent.getParentSiteId() != null && parent.getParentSiteId() > 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "院系子站下不允许再挂子站"); + } + if (req.getSchId() == null) { + req.setSchId(parent.getSchId()); + req.setSchName(parent.getSchName()); + } else if (parent.getSchId() != null && !req.getSchId().equals(parent.getSchId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "院系子站与主站不属于同一学校"); + } + boolean wasTopLevel = site.getId() == null || site.getParentSiteId() == null + || site.getParentSiteId() == 0; + if (wasTopLevel && site.getId() != null && siteRepository.existsByParentSiteId(site.getId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "该站点下已有院系子站,不能改为院系子站"); + } + site.setParentSiteId(parentSiteId); + } + + /** + * config_json 校验(docs/09 §3.1,替代旧「空 Map 且长度>2」判定): + * ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线; + * ③ 已知键按类型校验(domain 域名 / public_base_url 完整 URL / logo、favicon 资产路径)。 + * 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行—— + * 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。 + */ + private void validateConfigJson(String configJson) { + if (!StringUtils.hasText(configJson)) { + return; + } + JsonNode node; + try { + node = CONFIG_MAPPER.readTree(configJson); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "站点配置不是合法的 JSON"); + } + if (!node.isObject()) { + throw new BizException(ErrorCode.PARAM_ERROR, "站点配置必须是 JSON 对象"); + } + node.fields().forEachRemaining(entry -> { + String key = entry.getKey(); + if (RESERVED_CONFIG_KEYS.contains(key)) { + return; + } + if (!CONFIG_KEY_PATTERN.matcher(key).matches()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)"); + } + JsonNode value = entry.getValue(); + if (!value.isTextual()) { + return; + } + String text = value.asText(); + if (!StringUtils.hasText(text)) { + return; + } + if (text.length() > CONFIG_VALUE_MAX_LEN) { + throw new BizException(ErrorCode.PARAM_ERROR, "站点配置值过长:" + key + "(上限 500 字)"); + } + switch (key) { + case "domain": + if (!HOST_PATTERN.matcher(text).matches()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "绑定域名格式不正确:" + text + "(示例 www.xxx.edu.cn,不带协议)"); + } + break; + case "public_base_url": + if (!URL_PATTERN.matcher(text).matches()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "对外基地址必须是完整的 http(s):// 地址:" + text); + } + break; + case "logo": + case "favicon": + if (!ASSET_PATTERN.matcher(text).matches()) { + throw new BizException(ErrorCode.PARAM_ERROR, + key + " 必须是站点上传路径(/ 开头)或 http(s) 地址"); + } + break; + default: + break; + } + }); + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/ThemeService.java b/src/main/java/com/yunhai/cms/service/impl/ThemeService.java new file mode 100644 index 0000000..115eae8 --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/ThemeService.java @@ -0,0 +1,62 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.model.vo.ThemeVo; +import com.yunhai.cms.theme.ThemeInfo; +import com.yunhai.cms.theme.ThemeRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/** + * 主题管理(方案 §5.1/§5.2):主题列表(含参数 schema)供管理端卡片选择与 + * 参数表单动态生成;screenshot 与预览资产经 /api/theme/{name}/asset/** 代理。 + */ +@Service +@RequiredArgsConstructor +public class ThemeService { + + private final ThemeRegistry themeRegistry; + + /** 可用主题(default 优先,其余按名称排序),管理端建站向导与站点编辑共用 */ + public List list() { + List result = new ArrayList<>(); + for (ThemeInfo info : themeRegistry.list()) { + result.add(ThemeVo.of(info)); + } + return result; + } + + /** 未注册主题返回 null(白名单判断用) */ + public ThemeInfo get(String name) { + return themeRegistry.get(name); + } + + /** 未注册主题抛 PARAM_ERROR(预览接口明确 400 语义,不静默回退,方案 §5.5/T27) */ + public ThemeInfo requireRegistered(String name) { + ThemeInfo info = themeRegistry.get(name); + if (info == null) { + throw new com.yunhai.platform.apistd.BizException( + com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name); + } + return info; + } + + /** + * 提取预览 p_* 前缀参数并按主题 schema 过滤(方案 §5.5): + * key 为 theme.json 声明过的,# 由前端 URLSearchParams 自动编码为 %23(容器已解码)。 + */ + public java.util.Map extractPreviewParams(ThemeInfo info, + java.util.Map parameterMap) { + java.util.Map raw = new java.util.LinkedHashMap<>(); + if (parameterMap != null) { + for (java.util.Map.Entry e : parameterMap.entrySet()) { + if (e.getKey().startsWith("p_") && e.getValue() != null && e.getValue().length > 0) { + raw.put(e.getKey().substring(2), e.getValue()[0]); + } + } + } + return info.filterValidValues(raw); + } +} diff --git a/src/main/java/com/yunhai/cms/service/impl/ViewCounterService.java b/src/main/java/com/yunhai/cms/service/impl/ViewCounterService.java new file mode 100644 index 0000000..7d65d7b --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/ViewCounterService.java @@ -0,0 +1,58 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.repository.CmsArticleRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.annotation.PreDestroy; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * 浏览量异步累加(方案 §5.2):详情渲染 fire-and-forget UPDATE,失败忽略; + * 量大后改缓存批量聚合。单线程队列足够 1-2 校门户流量。 + */ +@Slf4j +@Service +public class ViewCounterService { + + private final CmsArticleRepository articleRepository; + private final TransactionTemplate transactionTemplate; + private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "cms-view-counter"); + thread.setDaemon(true); + return thread; + }); + + public ViewCounterService(CmsArticleRepository articleRepository, + TransactionTemplate transactionTemplate) { + this.articleRepository = articleRepository; + this.transactionTemplate = transactionTemplate; + } + + public void increment(Integer articleId) { + if (articleId == null) { + return; + } + executor.execute(() -> { + try { + transactionTemplate.executeWithoutResult( + status -> articleRepository.incrementViewCount(articleId)); + } catch (Exception e) { + log.debug("浏览量累加失败 articleId={}(忽略)", articleId, e); + } + }); + } + + @PreDestroy + public void shutdown() { + executor.shutdown(); + try { + executor.awaitTermination(3, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/com/yunhai/cms/theme/ThemeInfo.java b/src/main/java/com/yunhai/cms/theme/ThemeInfo.java new file mode 100644 index 0000000..94b2f13 --- /dev/null +++ b/src/main/java/com/yunhai/cms/theme/ThemeInfo.java @@ -0,0 +1,75 @@ +package com.yunhai.cms.theme; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 主题包元数据(theme.json 解析结果,方案 §4.2)。 + * 仅含包声明信息;注册与校验在 {@link ThemeRegistry}。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ThemeInfo { + + private Integer specVersion; + + private String name; + + private String title; + + private String version; + + private String author; + + private String description; + + /** 预览图相对包路径(缺省 assets/screenshot.png,方案 §4.1/§5.2) */ + private String screenshot; + + /** 轮播图设计尺寸约定(D2 决策点),如 1920x560 */ + private String bannerSize; + + private List params = new ArrayList<>(); + + public String screenshotPath() { + return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot; + } + + public ThemeParam param(String key) { + for (ThemeParam p : params) { + if (p.getKey() != null && p.getKey().equals(key)) { + return p; + } + } + return null; + } + + /** + * 按 schema 过滤 + 校验原始参数(方案 §5.3 合并语义): + * 未声明 key 丢弃、非法值丢弃;返回仅含合法声明的有序 Map。 + */ + public Map filterValidValues(Map raw) { + Map result = new LinkedHashMap<>(); + if (raw == null) { + return result; + } + for (Map.Entry entry : raw.entrySet()) { + Object rawKey = entry.getKey(); + Object rawValue = entry.getValue(); + if (rawKey == null || rawValue == null) { + continue; + } + ThemeParam schema = param(rawKey.toString()); + String value = rawValue.toString(); + if (schema != null && schema.isValidValue(value)) { + result.put(schema.getKey(), value); + } + } + return result; + } +} diff --git a/src/main/java/com/yunhai/cms/theme/ThemeParam.java b/src/main/java/com/yunhai/cms/theme/ThemeParam.java new file mode 100644 index 0000000..dfa013e --- /dev/null +++ b/src/main/java/com/yunhai/cms/theme/ThemeParam.java @@ -0,0 +1,75 @@ +package com.yunhai.cms.theme; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; +import java.util.regex.Pattern; + +/** + * 主题包可调参数声明(theme.json params[],方案 §4.2)。 + * 类型与取值约束:color 色值正则 / image 仅 /cms/upload/** / select 枚举 / text 短文本。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ThemeParam { + + public static final String TYPE_COLOR = "color"; + public static final String TYPE_IMAGE = "image"; + public static final String TYPE_SELECT = "select"; + public static final String TYPE_TEXT = "text"; + + /** color 合法值:#RGB/#RRGGBB/#RRGGBBAA(方案 §4.2,防 CSS 上下文注入) */ + public static final Pattern COLOR_PATTERN = Pattern.compile("^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"); + + /** param key 约束(方案 §4.1) */ + public static final Pattern KEY_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9_]{0,31}$"); + + private String key; + + /** 聚合进 :root{} 的 CSS 变量名(仅 color 类型允许,方案 §4.1/§9) */ + private String cssVar; + + private String label; + + private String type; + + @JsonProperty("default") + private String defaultValue; + + @JsonIgnoreProperties(ignoreUnknown = true) + @Data + public static class Option { + private String value; + private String label; + } + + private List

策略:jsoup Safelist.relaxed() 为基础,补 figure/figcaption(编辑器图片段落)、 + * 全局受限 style(仅排版类属性白名单),a/img 协议收紧;script/iframe/on* 一律剥除。 + * 列表页等纯文本字段由 Thymeleaf th:text 转义输出,th:utext 仅限本工具清洗过的内容。 + */ +public final class HtmlSanitizer { + + /** 允许保留的内联样式属性(排版用途),其余 style 值丢弃 */ + private static final Pattern SAFE_STYLE = + Pattern.compile("^(\\s*(text-align|color|background-color|font-size|font-weight" + + "|line-height|width|height|max-width|float|margin|padding)\\s*:\\s*[^;()]+;?)*\\s*$", + Pattern.CASE_INSENSITIVE); + + private static final Safelist WHITELIST = Safelist.relaxed() + .addTags("figure", "figcaption", "span") + .addAttributes(":all", "style") + .addAttributes("img", "style", "alt", "width", "height") + .addProtocols("a", "href", "http", "https", "mailto", "ftp", "#") + .addProtocols("img", "src", "http", "https", "data") + // 相对链接保留(day3 验收 B-9 根因修复):上传地址约定相对路径入库(B-1, + // FILE_WEB_PATH=/cms/upload/**),编辑器贴图的 img src 即相对 URL;jsoup 默认 + // preserveRelativeLinks=false 时相对 src/href 无法解析协议 → 整个属性被剥除, + // 保存后贴图全部断图。开启后相对 URL 原样放行(不参与协议校验), + // 绝对 URL 仍受上方协议白名单约束。 + .preserveRelativeLinks(true); + + private HtmlSanitizer() { + } + + /** + * 清洗富文本;空入参原样返回。 + * + *

baseUri 用占位 https 域(B-9 根因修复配套):jsoup 对带协议白名单的属性先做 + * absUrl 解析再校验协议,空 baseUri 下相对 URL 解析必失败 → 属性整体剥除(编辑器 + * 贴图保存后断图)。给占位 base 后相对 URL 解析为 https 形态通过校验,再由 + * preserveRelativeLinks(true) 回写原始相对值——绝对 URL 的协议白名单约束不变。 + */ + public static String clean(String html) { + if (html == null || html.isEmpty()) { + return html; + } + String cleaned = Jsoup.clean(html, "https://sanitizer.base.invalid/", WHITELIST); + return stripUnsafeStyle(cleaned); + } + + /** + * 纯文本提取(摘要兜底、搜索片段用):去标签 + 折叠空白。 + */ + public static String toText(String html, int maxLength) { + if (html == null || html.isEmpty()) { + return ""; + } + String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim(); + if (maxLength > 0 && text.length() > maxLength) { + return text.substring(0, maxLength); + } + return text; + } + + /** + * jsoup 原生不支持按值过滤属性,二次遍历按 SAFE_STYLE 白名单过滤 style: + * 不匹配白名单的 style 整个属性丢弃(A-07 修复——原先只删含 url()/expression 的, + * 与类注释"其余 style 值丢弃"不符);jsoup 清洗阶段已保证 style 值不含引号逃逸。 + */ + private static String stripUnsafeStyle(String html) { + org.jsoup.nodes.Document doc = org.jsoup.Jsoup.parseBodyFragment(html); + for (org.jsoup.nodes.Element el : doc.select("[style]")) { + String style = el.attr("style"); + if (style == null || !SAFE_STYLE.matcher(style).matches()) { + el.removeAttr("style"); + } + } + return doc.body().html(); + } +} diff --git a/src/main/java/com/yunhai/cms/utils/PortalUrlHelper.java b/src/main/java/com/yunhai/cms/utils/PortalUrlHelper.java new file mode 100644 index 0000000..6cb088a --- /dev/null +++ b/src/main/java/com/yunhai/cms/utils/PortalUrlHelper.java @@ -0,0 +1,220 @@ +package com.yunhai.cms.utils; + +import javax.servlet.http.HttpServletRequest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 门户 URL 生成器(方案 §8.6 前置约定2):所有链接、主题资产、canonical 统一经此生成, + * 禁止模板硬编码绝对地址——base 域子路径与学校独立域名两种暴露形态共用同一套渲染。 + * + *

基准 = 站点 config_json.public_base_url(绑学校域名后为学校域), + * 未配置时从当前请求派生(预览/平台内跳转形态)。 + */ +public class PortalUrlHelper { + + /** 正文/单页内容里出现的上传资源地址:可选历史绝对前缀 + 相对路径(B-1 渲染期统一补基准) */ + private static final Pattern UPLOAD_URL_IN_CONTENT = + Pattern.compile("(?:https?://[^/\\s\"']+)?((?:/cms)?/upload/[^\\s\"']+)"); + + /** 不带尾斜杠的门户基地址,形如 {scheme}://{host}{context}/portal/{siteCode} 或 https://www.xxx.edu.cn */ + private final String baseUrl; + + private final String siteCode; + + /** 当前皮肤包名:进入资产 URL(B-3,换肤即换 URL 使浏览器/CDN 缓存天然失效) */ + private final String theme; + + /** + * base 域对外基地址(cms.portal.base-origin,含 context-path,如 https://base.yunhaixingtong.com/cms)。 + * C-6:站点未配 public_base_url 时的规范派生基准;B-4:siteHome 无段形态退化目标。空 = 未配置。 + */ + private final String baseOrigin; + + /** 预览模式资产前缀(非 null 时 asset() 改走管理端代理,方案 §5.5 防资产串包) */ + private final String assetProxyPrefix; + + public PortalUrlHelper(String baseUrl, String siteCode, String theme) { + this(baseUrl, siteCode, theme, null, null); + } + + public PortalUrlHelper(String baseUrl, String siteCode, String theme, String baseOrigin) { + this(baseUrl, siteCode, theme, baseOrigin, null); + } + + private PortalUrlHelper(String baseUrl, String siteCode, String theme, String baseOrigin, + String assetProxyPrefix) { + this.baseUrl = trimTail(baseUrl); + this.siteCode = siteCode; + this.theme = theme; + this.baseOrigin = trimTail(baseOrigin); + this.assetProxyPrefix = assetProxyPrefix; + } + + /** + * 预览模式工厂(方案 §5.5):资产 URL 指向管理端主题资产代理 /api/theme/{theme}/asset/**, + * 其余站内链接仍指向真实门户(点击回真实门户是已接受的预览局限)。 + */ + public static PortalUrlHelper forPreview(HttpServletRequest request, String siteCode, String theme) { + PortalUrlHelper base = fromRequest(request, siteCode, theme); + // 显式走 5 参构造:第 4 参是 base-origin,代理前缀必须落第 5 参(避免误绑 4 参重载) + return new PortalUrlHelper(base.baseUrl, siteCode, theme, null, "/api/theme/" + theme + "/asset/"); + } + + /** + * 从当前请求派生门户基地址(public_base_url / base-origin 均未配置时的兜底; + * 反代形态经 ForwardedHeaderFilter 修正后的 scheme/host/port 已反映在请求上,B-2)。 + */ + public static PortalUrlHelper fromRequest(HttpServletRequest request, String siteCode, String theme) { + String scheme = request.getScheme(); + String host = request.getServerName(); + int port = request.getServerPort(); + String context = request.getContextPath(); + StringBuilder sb = new StringBuilder(scheme).append("://").append(host); + boolean defaultPort = "http".equals(scheme) && port == 80 || "https".equals(scheme) && port == 443; + if (!defaultPort) { + sb.append(':').append(port); + } + sb.append(context).append("/portal/").append(siteCode); + return new PortalUrlHelper(sb.toString(), siteCode, theme); + } + + /** + * base 域规范形态工厂(C-6):站点未配 public_base_url 且平台配了 cms.portal.base-origin 时, + * 以 base 域规范路径为基准(base-origin + /portal/{siteCode}),不随访问 host 漂移—— + * 方案 §8.6「默认派生 https://base.yunhaixingtong.com/cms/portal/{code}」的落地。 + */ + public static PortalUrlHelper fromBaseOrigin(String baseOrigin, String siteCode, String theme) { + return new PortalUrlHelper(trimTail(baseOrigin) + "/portal/" + siteCode, siteCode, theme, baseOrigin); + } + + /** 首页 */ + public String home() { + return baseUrl + "/"; + } + + /** + * 其他站点首页("专业院系"下拉用,《院系子站方案》):目标站配置了 public_base_url 时以它为准; + * 否则把当前基准里的站点段替换为目标站点(base 域子路径形态); + * 学校域形态(当前基准无 /portal/ 段)时退化为 base 域规范路径(B-4,base-origin 未配置才回 "/")。 + */ + public String siteHome(String targetCode, String targetPublicBaseUrl) { + if (targetPublicBaseUrl != null && !targetPublicBaseUrl.trim().isEmpty()) { + return trimTail(targetPublicBaseUrl) + "/"; + } + int idx = baseUrl.lastIndexOf("/portal/"); + if (idx >= 0) { + return baseUrl.substring(0, idx + "/portal/".length()) + targetCode + "/"; + } + if (baseOrigin != null && !baseOrigin.isEmpty()) { + return baseOrigin + "/portal/" + targetCode + "/"; + } + return "/"; + } + + /** 栏目列表/单页(type=3 外链不在此渲染,直接取 linkUrl) */ + public String category(Object categoryId) { + return baseUrl + "/c/" + categoryId; + } + + /** 文章详情 */ + public String article(Object articleId) { + return baseUrl + "/a/" + articleId; + } + + /** 站内搜索 */ + public String search(String q, Integer page) { + String url = baseUrl + "/search?q=" + urlEncode(q == null ? "" : q); + return page != null && page > 1 ? url + "&page=" + page : url; + } + + /** + * 主题资产(css/图片):常规经 PortalAssetController 按站点皮肤解析;预览模式走管理端代理。 + * URL 含主题段(B-3):换肤即换 URL,浏览器/CDN 的 max-age 缓存天然失效。 + */ + public String asset(String path) { + if (assetProxyPrefix != null) { + return assetProxyPrefix + trimLead(path); + } + String themeSeg = theme == null || theme.isEmpty() ? "default" : theme; + return baseUrl + "/assets/" + themeSeg + "/" + trimLead(path); + } + + /** + * 上传资源地址补基准(B-1):入库的 webUrl 一律为相对路径(FILE_WEB_PATH 相对形态), + * 渲染时按当前基准补 origin 成完整 URL——base 域/学校域/任意外部域访问不断图。 + * 已是完整 http(s) 地址(外链图片等)原样返回;空值原样返回。 + */ + public String upload(String storedUrl) { + if (storedUrl == null || storedUrl.trim().isEmpty()) { + return storedUrl; + } + String s = storedUrl.trim(); + if (s.startsWith("http://") || s.startsWith("https://")) { + return s; + } + return originOf(baseUrl) + (s.startsWith("/") ? s : "/" + s); + } + + /** + * 富文本内容后处理(B-1):把正文里历史绝对前缀(http://localhost:8072 等) + * 与相对形态的上传地址统一重写为当前基准下的完整 URL。外链图片不受影响。 + */ + public String rewriteUploadUrls(String html) { + if (html == null || html.isEmpty()) { + return html; + } + Matcher matcher = UPLOAD_URL_IN_CONTENT.matcher(html); + StringBuffer sb = new StringBuffer(html.length()); + while (matcher.find()) { + matcher.appendReplacement(sb, Matcher.quoteReplacement(upload(matcher.group(1)))); + } + matcher.appendTail(sb); + return sb.toString(); + } + + public String getSiteCode() { + return siteCode; + } + + public String getBaseUrl() { + return baseUrl; + } + + /** 从基地址截取 scheme://host[:port](去掉 /portal 段及任何路径),upload() 补基准用 */ + private static String originOf(String baseUrl) { + if (baseUrl == null) { + return ""; + } + int schemeEnd = baseUrl.indexOf("://"); + if (schemeEnd < 0) { + return ""; + } + int pathStart = baseUrl.indexOf('/', schemeEnd + 3); + return pathStart < 0 ? baseUrl : baseUrl.substring(0, pathStart); + } + + private static String trimTail(String url) { + String s = url == null ? "" : url.trim(); + while (s.endsWith("/")) { + s = s.substring(0, s.length() - 1); + } + return s; + } + + private static String trimLead(String path) { + String s = path == null ? "" : path; + while (s.startsWith("/")) { + s = s.substring(1); + } + return s; + } + + private static String urlEncode(String value) { + try { + return java.net.URLEncoder.encode(value, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return value; + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..20c26dc --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,100 @@ +server: + port: 8072 + servlet: + context-path: /cms + +spring: + application: + name: cms + datasource: + url: jdbc:mysql://localhost:3306/bsch_cms?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + open-in-view: false + properties: + hibernate: + jdbc: + time_zone: Asia/Shanghai + jackson: + # 管理端 JSON 时间格式(前端 DatePicker 对齐 yyyy-MM-dd HH:mm:ss) + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + # ===== 门户 SSR ===== + thymeleaf: + cache: false # 本地改模板热生效;生产注入覆盖为 true(方案 §6.2) + prefix: classpath:/templates/ + suffix: .html + encoding: UTF-8 + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + # 1-2 校规模:Caffeine 本地缓存(对齐缓存决策「单实例部署」结论) + cache: + type: caffeine + caffeine: + spec: maximumSize=10000,expireAfterWrite=5m + +# ===== 基础平台 yh 配置(UserHome 同款三件套)===== +yh: + auth: + enabled: true + sso: + server-url-prefix: http://sso.yunhaixingtong.com + # 本地 dev 必须指向前端 dev server(8073):CAS 验票回跳走 vite 代理, + # 配合前端代理的 cookiePathRewrite 才能让 JSESSIONID(path=/cms) 进入 /api 请求; + # 指回 8072 会导致 8073 页面会话死循环。生产由 projman 注入覆盖,不受此值影响。 + client-host-url: http://localhost:8073 + allow-headers: all + allow-origins: all + # ⚠️ 只拦管理端 API;门户 /portal/** 必须匿名可访问(SEO/首屏刚需),勿放进 pattern + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # UserBase 注册后分配(进白名单),与 yh.open 保持一致;生产由 projman 注入 + app-key: 100005 + app-secret: 2c7885de594742cc840b8762910b2d2e + open: + enabled: true + base-host: http://base.yunhaixingtong.com + base-path: /userbase + app-key: 100005 + app-secret: 2c7885de594742cc840b8762910b2d2e + +# ===== CMS 专属配置 ===== +cms: + # 超管判定:BoaUser.type == 1(UserBase sys_user.type=1 平台约定,无注入配置); + # 校级权限按 currentRole.schId → site.sch_id 校验(方案 §7.2) + portal: + # 详情页浏览器缓存(秒),配合后端 5 分钟数据缓存(方案 §8.3) + detail-max-age: 300 + # base 域对外基地址(C-6,方案 §8.6「未配 public_base_url 默认派生 base 域规范路径」的固化配置): + # 含 context-path,如 https://base.yunhaixingtong.com/cms;生产由 projman 注入。 + # 为空(本地开发默认)时按当前请求派生(反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。 + base-origin: "" + +logging: + level: + com.yunhai.cms: debug + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never + +# 文件上传(同 UserHome 双 key 约定,方案 §7.3) +# FILE_WEB_PATH 只接受相对路径(默认 /cms/upload/,含 context-path)或公网可达绝对地址; +# 禁止填 localhost/内网绝对地址——webUrl 入库后非本机访问全部断图(B-1/C-5)。 +# 门户渲染侧由 PortalUrlHelper.upload() 按访问基准补全为完整 URL。 +FILE_PATH: D:/bsch/cms/upload/ +FILE_WEB_PATH: /cms/upload/ diff --git a/src/main/resources/sql/cms_init.sql b/src/main/resources/sql/cms_init.sql new file mode 100644 index 0000000..20f5c85 --- /dev/null +++ b/src/main/resources/sql/cms_init.sql @@ -0,0 +1,149 @@ +-- ============================================================ +-- bsch_cms 初始化脚本 +-- 约定:JPA ddl-auto=update 自动建表;本脚本用于生产显式初始化/评审参考 +-- 多租户:site 表挂 sch_id(逻辑外键 → UserBase sys_group.id),文章只挂 site_id, +-- 编辑权限经 currentRole.schId → site.sch_id 映射校验 +-- ============================================================ + +-- 1. 站点表(多租户根;一校一主站 + N 院系子站,见《院系子站方案.md》) +CREATE TABLE IF NOT EXISTS `cms_site` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `code` VARCHAR(64) NOT NULL COMMENT '站点编码,URL 路径用(/portal/{code})', + `parent_site_id` INT NOT NULL DEFAULT 0 COMMENT '上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层)', + `sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)', + `sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)', + `name` VARCHAR(128) NOT NULL COMMENT '站点名称', + `theme` VARCHAR(64) NULL DEFAULT 'default' COMMENT '皮肤包名(classpath:/templates/themes/{theme}/)', + `config_json` JSON NULL COMMENT '站点配置(logo/联系方式/页脚等;预留 wechat 节点)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用', + `create_by` VARCHAR(64) NULL COMMENT '创建人账号', + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_site_code` (`code`), + KEY `idx_site_sch` (`sch_id`), + KEY `idx_site_parent` (`parent_site_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点(一校一主站 + N 院系子站)'; + +-- 2. 栏目表(站点内树形) +CREATE TABLE IF NOT EXISTS `cms_category` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点(逻辑外键 → cms_site.id)', + `parent_id` INT NOT NULL DEFAULT 0 COMMENT '父栏目ID(0=根)', + `name` VARCHAR(64) NOT NULL COMMENT '栏目名称', + `type` TINYINT NOT NULL DEFAULT 1 COMMENT '栏目类型:1=文章列表 2=单页 3=外链', + `link_url` VARCHAR(500) NULL COMMENT '外链地址(type=3 必填)', + `template` VARCHAR(64) NULL COMMENT '栏目模板覆盖(空=按 type 取默认)', + `content` LONGTEXT NULL COMMENT '单页栏目内容(type=2 时门户直接渲染;方案 §8.1 要求,§5.1 原稿漏列此处补齐)', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '同级排序(升序)', + `seo_title` VARCHAR(200) NULL COMMENT 'SEO 标题(空=用栏目名)', + `seo_description` VARCHAR(500) NULL COMMENT 'SEO 描述', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0=隐藏 1=显示', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_cat_tree` (`site_id`, `parent_id`, `sort_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 栏目(树形)'; + +-- 3. 文章表 +CREATE TABLE IF NOT EXISTS `cms_article` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点(隔离/查询维度)', + `category_id` INT NOT NULL COMMENT '所属栏目(逻辑外键 → cms_category.id)', + `title` VARCHAR(200) NOT NULL COMMENT '标题', + `summary` VARCHAR(500) NULL COMMENT '摘要(列表/SEO 用,空=截取正文)', + `content` LONGTEXT NULL COMMENT '正文 HTML(wangEditor 产出,保存时 jsoup 白名单清洗)', + `cover_image` VARCHAR(500) NULL COMMENT '封面图 URL', + `author_account` VARCHAR(64) NULL COMMENT '作者账号(发布时从 BoaUser 填充)', + `author_name` VARCHAR(64) NULL COMMENT '作者姓名(冗余展示)', + `source` VARCHAR(128) NULL COMMENT '来源(转载出处)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0=草稿 1=已发布 2=已下架', + `topmost` TINYINT NOT NULL DEFAULT 0 COMMENT '置顶:0/1', + `publish_time` DATETIME NULL COMMENT '发布时间(空=保存即发布;未来=定时发布)', + `offline_time` DATETIME NULL COMMENT '下架时间(可选)', + `view_count` INT NOT NULL DEFAULT 0 COMMENT '浏览量(MVP 异步累加)', + `ext_json` JSON NULL COMMENT '扩展字段(保留键:wechat=推送回执预留、albums=图集演进)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_art_list` (`site_id`, `category_id`, `status`, `publish_time`), + KEY `idx_art_site_top` (`site_id`, `status`, `topmost`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 文章'; + +-- 4. 附件表(正文图片 + 文档附件) +CREATE TABLE IF NOT EXISTS `cms_attachment` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `article_id` INT NULL COMMENT '关联文章(编辑器图片保存时回填)', + `file_name` VARCHAR(255) NOT NULL COMMENT '原始文件名', + `file_path` VARCHAR(500) NOT NULL COMMENT '存储路径(相对 FILE_PATH)', + `web_url` VARCHAR(500) NOT NULL COMMENT 'Web 访问 URL', + `file_size` BIGINT NULL COMMENT '字节数', + `mime_type` VARCHAR(128) NULL COMMENT 'MIME 类型', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_att_article` (`article_id`), + KEY `idx_att_site` (`site_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 附件'; + +-- 5. 轮播/推荐位表(首页 banner、图片新闻位等) +CREATE TABLE IF NOT EXISTS `cms_banner` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `position` VARCHAR(32) NOT NULL DEFAULT 'home' COMMENT '展示位标识(home=首页轮播;皮肤包约定其他位)', + `title` VARCHAR(200) NOT NULL COMMENT '标题(alt 文案/无图兜底)', + `image_url` VARCHAR(500) NOT NULL COMMENT '图片 URL(上传至 FILE_PATH)', + `link_type` TINYINT NOT NULL DEFAULT 1 COMMENT '跳转类型:1=文章 2=栏目 3=外链', + `link_value` VARCHAR(500) NULL COMMENT '跳转目标(文章ID/栏目ID/URL,按 link_type 解释)', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '同级排序(升序)', + `start_time` DATETIME NULL COMMENT '上线时间(空=立即;未来=定时上线)', + `end_time` DATETIME NULL COMMENT '下线时间(可选,门户渲染时过滤)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_banner` (`site_id`, `position`, `status`, `sort_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 轮播/推荐位'; + +-- 6. 友情链接表(页脚/首页分组成员) +CREATE TABLE IF NOT EXISTS `cms_link` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `group_name` VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '分组(上级部门/兄弟院校/合作单位…)', + `name` VARCHAR(128) NOT NULL COMMENT '链接名称', + `logo_url` VARCHAR(500) NULL COMMENT 'Logo URL(可选,文字链可空)', + `link_url` VARCHAR(500) NOT NULL COMMENT '链接地址', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '组内排序(升序)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0=隐藏 1=显示', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_link` (`site_id`, `group_name`, `sort_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 友情链接'; + +-- ============================================================ +-- 示例数据(联调首屏用,按需执行) +-- ============================================================ +-- INSERT INTO cms_site (code, sch_id, sch_name, name, theme) +-- VALUES ('demo', 1, 'XX学校', 'XX学校门户网站', 'default'); +-- +-- INSERT INTO cms_category (site_id, parent_id, name, type, sort_no) +-- VALUES (1, 0, '新闻中心', 1, 1), (1, 0, '通知公告', 1, 2), (1, 0, '学校简介', 2, 3); +-- +-- INSERT INTO cms_banner (site_id, position, title, image_url, link_type, sort_no) +-- VALUES (1, 'home', '欢迎访问XX学校', '/upload/cms/demo/202609/example.jpg', 3, 1); +-- +-- INSERT INTO cms_link (site_id, group_name, name, link_url, sort_no) +-- VALUES (1, '上级部门', '教育部', 'http://www.moe.gov.cn', 1); diff --git a/src/main/resources/sql/fix_upload_weburl_relative.sql b/src/main/resources/sql/fix_upload_weburl_relative.sql new file mode 100644 index 0000000..31ebbe6 --- /dev/null +++ b/src/main/resources/sql/fix_upload_weburl_relative.sql @@ -0,0 +1,48 @@ +-- ============================================================ +-- B-1/C-5 存量数据修复:上传资源绝对地址 → 相对路径(一次性,幂等可重复执行) +-- 日期:2026-09-05 +-- 背景:FILE_WEB_PATH 历史默认值为 http://localhost:8072/cms/upload/,上传返回的 +-- webUrl 连同正文 img src / 封面 / 轮播图 / 友链 logo / 站点 logo/favicon +-- 以该绝对地址入库,非 localhost 访问整站断图。 +-- 现约定:webUrl 一律只存相对路径(/cms/upload/**),渲染期由 +-- PortalUrlHelper.upload() 按访问基准补全为完整 URL(B-1 方案 2)。 +-- 正文中的历史绝对前缀另有渲染期兜底重写,本脚本用于库内彻底清理。 +-- 语义:只替换精确前缀 http://localhost:8072/cms/upload/,不影响其他绝对地址; +-- 如历史环境曾配过其他主机名/端口的 FILE_WEB_PATH,请按需追加同型 REPLACE。 +-- ============================================================ + +-- 1. 附件表 web_url +UPDATE cms_attachment +SET web_url = REPLACE(web_url, 'http://localhost:8072/cms/upload/', '/cms/upload/') +WHERE web_url LIKE 'http://localhost:8072/cms/upload/%'; + +-- 2. 文章正文 img src +UPDATE cms_article +SET content = REPLACE(content, 'http://localhost:8072/cms/upload/', '/cms/upload/') +WHERE content LIKE '%http://localhost:8072/cms/upload/%'; + +-- 3. 文章封面 +UPDATE cms_article +SET cover_image = REPLACE(cover_image, 'http://localhost:8072/cms/upload/', '/cms/upload/') +WHERE cover_image LIKE 'http://localhost:8072/cms/upload/%'; + +-- 4. 单页栏目内容 +UPDATE cms_category +SET content = REPLACE(content, 'http://localhost:8072/cms/upload/', '/cms/upload/') +WHERE content LIKE '%http://localhost:8072/cms/upload/%'; + +-- 5. 轮播图 +UPDATE cms_banner +SET image_url = REPLACE(image_url, 'http://localhost:8072/cms/upload/', '/cms/upload/') +WHERE image_url LIKE 'http://localhost:8072/cms/upload/%'; + +-- 6. 友链 logo +UPDATE cms_link +SET logo_url = REPLACE(logo_url, 'http://localhost:8072/cms/upload/', '/cms/upload/') +WHERE logo_url LIKE 'http://localhost:8072/cms/upload/%'; + +-- 7. 站点配置 logo/favicon(config_json 内嵌字符串,整串替换安全: +-- 该前缀只会出现在上传地址值里,不会与 JSON 结构字符重叠) +UPDATE cms_site +SET config_json = REPLACE(config_json, 'http://localhost:8072/cms/upload/', '/cms/upload/') +WHERE config_json LIKE '%http://localhost:8072/cms/upload/%'; diff --git a/src/main/resources/templates/error/400.html b/src/main/resources/templates/error/400.html new file mode 100644 index 0000000..3c7e32b --- /dev/null +++ b/src/main/resources/templates/error/400.html @@ -0,0 +1,26 @@ + + + + + + + 请求有误 + + + +

+
400
+

请求参数有误

+

页面地址中的参数不正确(如页码格式错误)。
请修正后重试,或返回门户首页继续浏览。

+ 返回门户首页 +
+ + diff --git a/src/main/resources/templates/error/404.html b/src/main/resources/templates/error/404.html new file mode 100644 index 0000000..83ad770 --- /dev/null +++ b/src/main/resources/templates/error/404.html @@ -0,0 +1,26 @@ + + + + + + + 页面不存在 + + + +
+
404
+

页面不存在或已下线

+

您访问的页面可能已被删除、更改地址或暂时不可用。
请检查网址是否正确,或返回门户首页继续浏览。

+ 返回门户首页 +
+ + diff --git a/src/main/resources/templates/error/500.html b/src/main/resources/templates/error/500.html new file mode 100644 index 0000000..e2f6d75 --- /dev/null +++ b/src/main/resources/templates/error/500.html @@ -0,0 +1,26 @@ + + + + + + + 服务暂时不可用 + + + +
+
500
+

服务暂时不可用

+

系统开小差了,工程师正在抢修。
请稍后刷新重试,或返回门户首页继续浏览。

+ 返回门户首页 +
+ + diff --git a/src/main/resources/templates/error/error.html b/src/main/resources/templates/error/error.html new file mode 100644 index 0000000..976119f --- /dev/null +++ b/src/main/resources/templates/error/error.html @@ -0,0 +1,26 @@ + + + + + + + 出错了 + + + +
+
:(
+

出错了

+

请求处理出现异常,请稍后重试。
如持续出现,请联系管理员。

+ 返回门户首页 +
+ + diff --git a/src/main/resources/templates/themes/blue-white/README.md b/src/main/resources/templates/themes/blue-white/README.md new file mode 100644 index 0000000..4e80766 --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/README.md @@ -0,0 +1,23 @@ +# blue-white 皮肤包 + +由 default 派生的演示皮肤(方案 §7)。**升级随版本发布,不在站点间复制目录**。 + +## 数据契约(模板可取用的 model 属性全集,§4.3) + +| 页面 | 属性 | +| --- | --- | +| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/asset/search/siteHome)、`themeBase`(=`themes/blue-white`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) | +| home | `banners`(时间窗内 home 位)、`topArticles`(置顶+最新 6)、`categoryBlocks`(LIST 型根栏目块,各 top 8) | +| list / search | `listData`(分页)、`category`(list)、`q` + `searchMode`(search) | +| detail | `article`、`prevArticle`、`nextArticle`、`currentCategoryId` | +| single | `category`(含 `content`)、`currentCategoryId` | + +原则:皮肤不得假设"首页一定有轮播",对空集合优雅降级。 + +## 轮播图尺寸约定(bannerSize) + +1920x560(管理端上传端按此引导裁剪,D2 决策点)。 + +## 可调参数 + +见 `theme.json` 的 `params`(color 进 CSS 变量;select 供模板 `th:if` 分支布局)。 diff --git a/src/main/resources/templates/themes/blue-white/assets/css/main.css b/src/main/resources/templates/themes/blue-white/assets/css/main.css new file mode 100644 index 0000000..aeeb344 --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/assets/css/main.css @@ -0,0 +1,332 @@ +/* ============================================================ + * blue-white 皮肤包样式(default 派生)(方案 §8.2) + * 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪,D2 决策点确认)。 + * ============================================================ */ +:root { + --primary: #2f6bd8; + --primary-dark: #1d4fae; + --text: #333; + --text-light: #777; + --bg: #f7f9fc; + --card-bg: #fff; + --border: #e3e9f2; + --max-width: 1200px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} +a { color: inherit; text-decoration: none; } +a:hover { color: var(--primary); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { color: var(--text-light); text-align: center; padding: 32px 0; } + +/* ===== 头部 ===== */ +.site-header { background: var(--card-bg); border-bottom: 1px solid var(--border); } +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.site-brand { display: flex; align-items: center; gap: 12px; } +.site-logo { height: 44px; } +.site-name { font-size: 26px; font-weight: 700; color: var(--primary); } +.site-search { display: flex; } +.site-search input { + border: 1px solid var(--border); + border-right: none; + padding: 8px 12px; + width: 220px; + outline: none; +} +.site-search button { + border: none; + background: var(--primary); + color: #fff; + padding: 8px 18px; + cursor: pointer; +} + +/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */ +/* 悬停展开白底纵向列表,右对齐 + 超高滚动(院系数十个子站场景) */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + padding: 8px 14px; + cursor: pointer; + color: var(--primary); + font-weight: 600; + white-space: nowrap; +} +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 110; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: 9px 16px; + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: #f2f6fc; color: var(--primary); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } +.site-nav { background: var(--primary); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + flex-wrap: wrap; +} +.site-nav li a { + display: block; + padding: 12px 22px; + color: #fff; +} +.site-nav li:hover a, .site-nav li.active a { background: var(--primary-dark); } + +/* ===== 二级下拉导航 ===== */ +/* 面板为白底纵向列表:需整体覆盖 .site-nav ul 的横向 flex 与内边距 */ +.site-nav > ul > li { position: relative; } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .85; } +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 168px; + max-width: none; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 100; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: 9px 16px; + color: var(--text); + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a:hover { background: #f2f6fc; color: var(--primary); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播 ===== */ +.banner { position: relative; max-width: var(--max-width); margin: 16px auto; overflow: hidden; } +.banner-slides { position: relative; height: 420px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 12px 20px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, .6)); +} +.banner-dots { + position: absolute; + bottom: 12px; right: 20px; + display: flex; gap: 8px; +} +.banner-dots button { + width: 10px; height: 10px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .6); + cursor: pointer; +} +.banner-dots button.active { background: #fff; } + +/* ===== 通用区块 ===== */ +.top-strip, .category-block { + max-width: var(--max-width); + margin: 16px auto; + padding: 20px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.section-title, .block-head h2 { + margin: 0 0 12px; + font-size: 20px; + border-left: 4px solid var(--primary); + padding-left: 10px; +} +.block-head { display: flex; align-items: baseline; justify-content: space-between; } +.more { font-size: 13px; color: var(--text-light); } +.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; } +.top-strip li, .block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px dashed var(--border); +} +.top-strip li:last-child, .block-list li:last-child { border-bottom: none; } +.top-strip li a, .block-list li a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-badge { + flex: none; + color: #fff; + background: var(--primary); + font-size: 12px; + padding: 1px 6px; + border-radius: 2px; +} + +/* ===== 列表页 ===== */ +.list-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 24px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 16px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; } +.page-title { + font-size: 22px; + border-left: 4px solid var(--primary); + padding-left: 10px; + margin: 0 0 18px; +} +.article-list { list-style: none; margin: 0; padding: 0; } +.article-list li { padding: 14px 0; border-bottom: 1px dashed var(--border); } +.item-title { font-size: 17px; } +.item-summary { + color: var(--text-light); + font-size: 14px; + margin: 6px 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.item-meta { display: flex; gap: 10px; align-items: center; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: 6px; justify-content: center; padding: 24px 0 4px; } +.pagination a { + min-width: 34px; + text-align: center; + padding: 6px 10px; + border: 1px solid var(--border); + background: var(--card-bg); +} +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情/单页 ===== */ +.detail-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.article-title { margin: 8px 0 12px; font-size: 26px; text-align: center; } +.article-meta { + display: flex; + gap: 18px; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.article-content { line-height: 1.9; font-size: 16px; padding-top: 20px; word-break: break-word; } +.article-content img { height: auto; } +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; } +.article-sibling { display: flex; flex-direction: column; gap: 8px; margin-top: 28px; font-size: 14px; } + +/* ===== 页脚 ===== */ +.site-footer { margin-top: 32px; background: var(--primary-dark); color: rgba(255, 255, 255, .85); } +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; } +.group-name { font-weight: 600; margin-right: 4px; } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 12px 16px 20px; + text-align: center; + font-size: 13px; +} +.footer-info p { margin: 4px 0; } + +/* ===== 移动端适配 ===== */ +@media (max-width: 768px) { + .header-inner { flex-direction: column; align-items: stretch; } + .site-search input { flex: 1; width: auto; } + .site-colleges { align-self: flex-start; } + .site-colleges .colleges-menu { left: 0; right: auto; min-width: 100%; } + .banner-slides { height: 200px; } + .detail-main, .list-main { padding: 18px 14px; } + .article-title { font-size: 22px; } + /* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: transparent; + min-width: 0; + padding: 0 0 6px; + } + .site-nav .sub-nav li a { color: rgba(255, 255, 255, .9); padding: 8px 16px 8px 34px; } + .site-nav .sub-nav li a:hover { background: var(--primary-dark); color: #fff; } +} + + +/* ===== blue-white 主题差异层(方案 §7:差异仅限 CSS + fragments)===== */ +/* 细线顶栏 + 大留白 + 卡片化列表 */ +.site-header { border-bottom: 1px solid var(--border); box-shadow: none; } +.home-main, .list-main, .detail-main { padding-top: 36px; padding-bottom: 56px; } +.article-list .article-title, .block-head, .page-title { letter-spacing: .02em; } +.article-list li, .banner, .banner-title { border-radius: 4px; } diff --git a/src/main/resources/templates/themes/blue-white/assets/favicon.png b/src/main/resources/templates/themes/blue-white/assets/favicon.png new file mode 100644 index 0000000..497cae1 Binary files /dev/null and b/src/main/resources/templates/themes/blue-white/assets/favicon.png differ diff --git a/src/main/resources/templates/themes/blue-white/assets/favicon.svg b/src/main/resources/templates/themes/blue-white/assets/favicon.svg new file mode 100644 index 0000000..cbfc6c6 --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/themes/blue-white/assets/js/main.js b/src/main/resources/templates/themes/blue-white/assets/js/main.js new file mode 100644 index 0000000..2314bae --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/assets/js/main.js @@ -0,0 +1,40 @@ +/* default 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */ +(function () { + var slides = document.querySelectorAll('.banner-slide'); + var dots = document.querySelectorAll('.banner-dots button'); + if (!slides.length) { + return; + } + var current = 0; + var timer = null; + + function show(index) { + slides[current].classList.remove('active'); + if (dots[current]) { + dots[current].classList.remove('active'); + } + current = (index + slides.length) % slides.length; + slides[current].classList.add('active'); + if (dots[current]) { + dots[current].classList.add('active'); + } + } + + function start() { + if (slides.length > 1) { + timer = window.setInterval(function () { + show(current + 1); + }, 5000); + } + } + + dots.forEach(function (dot, index) { + dot.addEventListener('click', function () { + window.clearInterval(timer); + show(index); + start(); + }); + }); + + start(); +})(); diff --git a/src/main/resources/templates/themes/blue-white/assets/screenshot.png b/src/main/resources/templates/themes/blue-white/assets/screenshot.png new file mode 100644 index 0000000..b6a7d0c Binary files /dev/null and b/src/main/resources/templates/themes/blue-white/assets/screenshot.png differ diff --git a/src/main/resources/templates/themes/blue-white/detail.html b/src/main/resources/templates/themes/blue-white/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/main/resources/templates/themes/blue-white/fragments/footer.html b/src/main/resources/templates/themes/blue-white/fragments/footer.html new file mode 100644 index 0000000..5c8e713 --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/fragments/footer.html @@ -0,0 +1,18 @@ + + + +
+ + +
+ diff --git a/src/main/resources/templates/themes/blue-white/fragments/head.html b/src/main/resources/templates/themes/blue-white/fragments/head.html new file mode 100644 index 0000000..9fcb6b4 --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/fragments/head.html @@ -0,0 +1,22 @@ + + + + + + + 页面标题 + + + + + + + + + + + + + diff --git a/src/main/resources/templates/themes/blue-white/fragments/header.html b/src/main/resources/templates/themes/blue-white/fragments/header.html new file mode 100644 index 0000000..3704f5d --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/fragments/header.html @@ -0,0 +1,53 @@ + + + + + diff --git a/src/main/resources/templates/themes/blue-white/home.html b/src/main/resources/templates/themes/blue-white/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/blue-white/list.html b/src/main/resources/templates/themes/blue-white/list.html new file mode 100644 index 0000000..fec0f2c --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/list.html @@ -0,0 +1,50 @@ + + + + +
+ +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/main/resources/templates/themes/blue-white/single.html b/src/main/resources/templates/themes/blue-white/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/main/resources/templates/themes/blue-white/theme.json b/src/main/resources/templates/themes/blue-white/theme.json new file mode 100644 index 0000000..4e12269 --- /dev/null +++ b/src/main/resources/templates/themes/blue-white/theme.json @@ -0,0 +1,16 @@ +{ + "specVersion": 1, + "name": "blue-white", + "title": "蓝白简约", + "version": "1.0.0", + "author": "yunhai", + "description": "细线顶栏 + 卡片化列表 + 大留白,适合高校与职业院校(方案 §7)。", + "screenshot": "assets/screenshot.png", + "bannerSize": "1920x560", + "params": [ + { "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#2f6bd8" }, + { "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深", "type": "color", "default": "#1d4fae" }, + { "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#f7f9fc" }, + { "key": "border", "cssVar": "--border", "label": "边框色", "type": "color", "default": "#e3e9f2" } + ] +} diff --git a/src/main/resources/templates/themes/classic-red/README.md b/src/main/resources/templates/themes/classic-red/README.md new file mode 100644 index 0000000..6277029 --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/README.md @@ -0,0 +1,23 @@ +# classic-red 皮肤包 + +由 default 派生的演示皮肤(方案 §7)。**升级随版本发布,不在站点间复制目录**。 + +## 数据契约(模板可取用的 model 属性全集,§4.3) + +| 页面 | 属性 | +| --- | --- | +| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/asset/search/siteHome)、`themeBase`(=`themes/classic-red`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) | +| home | `banners`(时间窗内 home 位)、`topArticles`(置顶+最新 6)、`categoryBlocks`(LIST 型根栏目块,各 top 8) | +| list / search | `listData`(分页)、`category`(list)、`q` + `searchMode`(search) | +| detail | `article`、`prevArticle`、`nextArticle`、`currentCategoryId` | +| single | `category`(含 `content`)、`currentCategoryId` | + +原则:皮肤不得假设"首页一定有轮播",对空集合优雅降级。 + +## 轮播图尺寸约定(bannerSize) + +1920x560(管理端上传端按此引导裁剪,D2 决策点)。 + +## 可调参数 + +见 `theme.json` 的 `params`(color 进 CSS 变量;select 供模板 `th:if` 分支布局)。 diff --git a/src/main/resources/templates/themes/classic-red/assets/css/main.css b/src/main/resources/templates/themes/classic-red/assets/css/main.css new file mode 100644 index 0000000..b80433f --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/assets/css/main.css @@ -0,0 +1,334 @@ +/* ============================================================ + * classic-red 皮肤包样式(default 派生)(方案 §8.2) + * 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪,D2 决策点确认)。 + * ============================================================ */ +:root { + --primary: #b01a2e; + --primary-dark: #7f1224; + --text: #333; + --text-light: #777; + --bg: #f7f5f2; + --card-bg: #fff; + --border: #e5e6eb; + --max-width: 1200px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} +a { color: inherit; text-decoration: none; } +a:hover { color: var(--primary); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { color: var(--text-light); text-align: center; padding: 32px 0; } + +/* ===== 头部 ===== */ +.site-header { background: var(--card-bg); border-bottom: 1px solid var(--border); } +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.site-brand { display: flex; align-items: center; gap: 12px; } +.site-logo { height: 44px; } +.site-name { font-size: 26px; font-weight: 700; color: var(--primary); } +.site-search { display: flex; } +.site-search input { + border: 1px solid var(--border); + border-right: none; + padding: 8px 12px; + width: 220px; + outline: none; +} +.site-search button { + border: none; + background: var(--primary); + color: #fff; + padding: 8px 18px; + cursor: pointer; +} + +/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */ +/* 悬停展开白底纵向列表,右对齐 + 超高滚动(院系数十个子站场景) */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + padding: 8px 14px; + cursor: pointer; + color: var(--primary); + font-weight: 600; + white-space: nowrap; +} +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 110; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: 9px 16px; + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: #f2f6fc; color: var(--primary); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } +.site-nav { background: var(--primary); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + flex-wrap: wrap; +} +.site-nav li a { + display: block; + padding: 12px 22px; + color: #fff; +} +.site-nav li:hover a, .site-nav li.active a { background: var(--primary-dark); } + +/* ===== 二级下拉导航 ===== */ +/* 面板为白底纵向列表:需整体覆盖 .site-nav ul 的横向 flex 与内边距 */ +.site-nav > ul > li { position: relative; } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .85; } +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 168px; + max-width: none; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 100; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: 9px 16px; + color: var(--text); + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a:hover { background: #f2f6fc; color: var(--primary); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播 ===== */ +.banner { position: relative; max-width: var(--max-width); margin: 16px auto; overflow: hidden; } +.banner-slides { position: relative; height: 420px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 12px 20px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, .6)); +} +.banner-dots { + position: absolute; + bottom: 12px; right: 20px; + display: flex; gap: 8px; +} +.banner-dots button { + width: 10px; height: 10px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .6); + cursor: pointer; +} +.banner-dots button.active { background: #fff; } + +/* ===== 通用区块 ===== */ +.top-strip, .category-block { + max-width: var(--max-width); + margin: 16px auto; + padding: 20px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.section-title, .block-head h2 { + margin: 0 0 12px; + font-size: 20px; + border-left: 4px solid var(--primary); + padding-left: 10px; +} +.block-head { display: flex; align-items: baseline; justify-content: space-between; } +.more { font-size: 13px; color: var(--text-light); } +.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; } +.top-strip li, .block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px dashed var(--border); +} +.top-strip li:last-child, .block-list li:last-child { border-bottom: none; } +.top-strip li a, .block-list li a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-badge { + flex: none; + color: #fff; + background: var(--primary); + font-size: 12px; + padding: 1px 6px; + border-radius: 2px; +} + +/* ===== 列表页 ===== */ +.list-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 24px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 16px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; } +.page-title { + font-size: 22px; + border-left: 4px solid var(--primary); + padding-left: 10px; + margin: 0 0 18px; +} +.article-list { list-style: none; margin: 0; padding: 0; } +.article-list li { padding: 14px 0; border-bottom: 1px dashed var(--border); } +.item-title { font-size: 17px; } +.item-summary { + color: var(--text-light); + font-size: 14px; + margin: 6px 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.item-meta { display: flex; gap: 10px; align-items: center; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: 6px; justify-content: center; padding: 24px 0 4px; } +.pagination a { + min-width: 34px; + text-align: center; + padding: 6px 10px; + border: 1px solid var(--border); + background: var(--card-bg); +} +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情/单页 ===== */ +.detail-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.article-title { margin: 8px 0 12px; font-size: 26px; text-align: center; } +.article-meta { + display: flex; + gap: 18px; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.article-content { line-height: 1.9; font-size: 16px; padding-top: 20px; word-break: break-word; } +.article-content img { height: auto; } +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; } +.article-sibling { display: flex; flex-direction: column; gap: 8px; margin-top: 28px; font-size: 14px; } + +/* ===== 页脚 ===== */ +.site-footer { margin-top: 32px; background: var(--primary-dark); color: rgba(255, 255, 255, .85); } +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; } +.group-name { font-weight: 600; margin-right: 4px; } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 12px 16px 20px; + text-align: center; + font-size: 13px; +} +.footer-info p { margin: 4px 0; } + +/* ===== 移动端适配 ===== */ +@media (max-width: 768px) { + .header-inner { flex-direction: column; align-items: stretch; } + .site-search input { flex: 1; width: auto; } + .site-colleges { align-self: flex-start; } + .site-colleges .colleges-menu { left: 0; right: auto; min-width: 100%; } + .banner-slides { height: 200px; } + .detail-main, .list-main { padding: 18px 14px; } + .article-title { font-size: 22px; } + /* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: transparent; + min-width: 0; + padding: 0 0 6px; + } + .site-nav .sub-nav li a { color: rgba(255, 255, 255, .9); padding: 8px 16px 8px 34px; } + .site-nav .sub-nav li a:hover { background: var(--primary-dark); color: #fff; } +} + + +/* ===== classic-red 主题差异层(方案 §7:差异仅限 CSS + fragments)===== */ +/* 宋体标题(中小学/党政风) */ +.site-name, .banner-title, .page-title, .section-title, .article-title, .item-title { + font-family: "SimSun", "STSong", "Songti SC", serif; +} +/* 头部样式参数(theme.json headerStyle=plain → 简洁顶栏) */ +.site-header.header-plain { background: #fff; border-bottom: 2px solid var(--primary); } +.site-header.header-plain .site-name { color: var(--primary-dark); } diff --git a/src/main/resources/templates/themes/classic-red/assets/favicon.png b/src/main/resources/templates/themes/classic-red/assets/favicon.png new file mode 100644 index 0000000..0761b47 Binary files /dev/null and b/src/main/resources/templates/themes/classic-red/assets/favicon.png differ diff --git a/src/main/resources/templates/themes/classic-red/assets/favicon.svg b/src/main/resources/templates/themes/classic-red/assets/favicon.svg new file mode 100644 index 0000000..6414b86 --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/themes/classic-red/assets/js/main.js b/src/main/resources/templates/themes/classic-red/assets/js/main.js new file mode 100644 index 0000000..2314bae --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/assets/js/main.js @@ -0,0 +1,40 @@ +/* default 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */ +(function () { + var slides = document.querySelectorAll('.banner-slide'); + var dots = document.querySelectorAll('.banner-dots button'); + if (!slides.length) { + return; + } + var current = 0; + var timer = null; + + function show(index) { + slides[current].classList.remove('active'); + if (dots[current]) { + dots[current].classList.remove('active'); + } + current = (index + slides.length) % slides.length; + slides[current].classList.add('active'); + if (dots[current]) { + dots[current].classList.add('active'); + } + } + + function start() { + if (slides.length > 1) { + timer = window.setInterval(function () { + show(current + 1); + }, 5000); + } + } + + dots.forEach(function (dot, index) { + dot.addEventListener('click', function () { + window.clearInterval(timer); + show(index); + start(); + }); + }); + + start(); +})(); diff --git a/src/main/resources/templates/themes/classic-red/assets/screenshot.png b/src/main/resources/templates/themes/classic-red/assets/screenshot.png new file mode 100644 index 0000000..400c8ac Binary files /dev/null and b/src/main/resources/templates/themes/classic-red/assets/screenshot.png differ diff --git a/src/main/resources/templates/themes/classic-red/detail.html b/src/main/resources/templates/themes/classic-red/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/main/resources/templates/themes/classic-red/fragments/footer.html b/src/main/resources/templates/themes/classic-red/fragments/footer.html new file mode 100644 index 0000000..5c8e713 --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/fragments/footer.html @@ -0,0 +1,18 @@ + + + +
+ + +
+ diff --git a/src/main/resources/templates/themes/classic-red/fragments/head.html b/src/main/resources/templates/themes/classic-red/fragments/head.html new file mode 100644 index 0000000..9fcb6b4 --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/fragments/head.html @@ -0,0 +1,22 @@ + + + + + + + 页面标题 + + + + + + + + + + + + + diff --git a/src/main/resources/templates/themes/classic-red/fragments/header.html b/src/main/resources/templates/themes/classic-red/fragments/header.html new file mode 100644 index 0000000..1d3035f --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/fragments/header.html @@ -0,0 +1,54 @@ + + + + + diff --git a/src/main/resources/templates/themes/classic-red/home.html b/src/main/resources/templates/themes/classic-red/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/classic-red/list.html b/src/main/resources/templates/themes/classic-red/list.html new file mode 100644 index 0000000..fec0f2c --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/list.html @@ -0,0 +1,50 @@ + + + + +
+ +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/main/resources/templates/themes/classic-red/single.html b/src/main/resources/templates/themes/classic-red/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/main/resources/templates/themes/classic-red/theme.json b/src/main/resources/templates/themes/classic-red/theme.json new file mode 100644 index 0000000..eec2f46 --- /dev/null +++ b/src/main/resources/templates/themes/classic-red/theme.json @@ -0,0 +1,17 @@ +{ + "specVersion": 1, + "name": "classic-red", + "title": "红蓝经典", + "version": "1.0.0", + "author": "yunhai", + "description": "深红横幅头图 + 通栏轮播 + 宋体标题,适合中小学与党政风格的学校(方案 §7)。", + "screenshot": "assets/screenshot.png", + "bannerSize": "1920x560", + "params": [ + { "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#b01a2e" }, + { "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深", "type": "color", "default": "#7f1224" }, + { "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#f7f5f2" }, + { "key": "headerStyle", "label": "头部样式", "type": "select", "default": "banner", + "options": [ { "value": "banner", "label": "横幅头图" }, { "value": "plain", "label": "简洁顶栏" } ] } + ] +} diff --git a/src/main/resources/templates/themes/default/assets/css/main.css b/src/main/resources/templates/themes/default/assets/css/main.css new file mode 100644 index 0000000..7e2919e --- /dev/null +++ b/src/main/resources/templates/themes/default/assets/css/main.css @@ -0,0 +1,936 @@ +/* ============================================================ + * default 皮肤包样式(方案 §8.2 · 视觉规范见 docs/design-system.md) + * 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。 + * 轮播图设计尺寸约定:1920×560(theme.json bannerSize)。 + * 规则:色值/字号/间距/圆角/阴影一律引用变量,禁止硬编码。 + * ============================================================ */ +:root { + /* —— 色板(design-system §2)—— */ + --primary: #1a5cb0; + --primary-dark: #12447f; + --primary-deep: #0d2f5c; + --primary-deeper: #0a2242; + --accent: #c9972c; + --text: #1f2933; + --text-light: #52606d; /* 次要文字(兼容 theme.json 参数名 --text-light) */ + --text-muted: #5f6b7a; /* 弱化文字:三种底色下对比度≥4.83(原#7b8794仅3.66不达标) */ + --bg: #f4f6f9; + --card-bg: #ffffff; + --surface-variant: #eef2f7; + --border: #dde3ec; + --border-strong: #c3cddb; + --white: #ffffff; + --scrim: rgba(10, 26, 48, 0.62); /* 轮播标题条渐变深端 */ + --overlay-dark: rgba(13, 47, 92, 0.55); /* 遮罩层 */ + --shadow-color: rgba(13, 47, 92, 0.10); + --print-ink: #000000; + --on-dark: #ffffff; + --on-dark-muted: rgba(255, 255, 255, 0.78); + --on-dark-faint: rgba(255, 255, 255, 0.55); + + /* —— 字号阶梯(design-system §3)—— */ + --fs-xs: 12px; + --fs-sm: 13px; + --fs-base: 14px; + --fs-md: 15px; + --fs-lg: 17px; + --fs-xl: 20px; + --fs-2xl: 24px; + --fs-3xl: 30px; + --fs-brand: 26px; + + /* —— 间距刻度(design-system §4)—— */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-6: 24px; + --sp-8: 32px; + --sp-12: 48px; + --sp-16: 64px; + + /* —— 圆角 / 阴影 / 层级 —— */ + --r-sm: 4px; + --r-md: 8px; + --r-lg: 12px; + --shadow-sm: 0 1px 2px var(--shadow-color); + --shadow-md: 0 4px 16px var(--shadow-color); + --z-header: 100; + --z-dropdown: 110; + + /* —— 栅格 —— */ + --max-width: 1200px; + --gutter: 24px; +} + +/* ===== 基础与重置 ===== */ +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + font-size: var(--fs-base); + line-height: 1.75; + color: var(--text); + background: var(--bg); +} +h1, h2, h3, h4, h5, h6 { line-height: 1.35; color: var(--text); margin: 0; } +p { margin: 0 0 var(--sp-3); } +ul, ol { margin: 0; padding: 0; } +a { color: inherit; text-decoration: none; transition: color .18s ease; } +a:hover { color: var(--primary); } +a:focus-visible, button:focus-visible, input:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} +img { max-width: 100%; height: auto; } +button { font-family: inherit; } +time, .item-meta { color: var(--text-muted); font-size: var(--fs-sm); } + +/* 跳过链接(无障碍) */ +.skip-link { + position: absolute; + left: -9999px; + top: 0; + z-index: 200; + background: var(--primary); + color: var(--on-dark); + padding: var(--sp-2) var(--sp-4); + border-radius: 0 0 var(--r-sm) 0; +} +.skip-link:focus { left: 0; color: var(--on-dark); } + +/* ===== 页头 ===== */ +/* 顶条:日期 + 专业院系(深蓝,真实功能项) */ +.topbar { + background: var(--primary-deeper); + color: var(--on-dark-muted); + font-size: var(--fs-sm); +} +.topbar-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: var(--sp-1) var(--gutter); + min-height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-4); +} +.topbar-date { white-space: nowrap; } +.topbar-side { display: flex; align-items: center; gap: var(--sp-4); } + +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: 4px 2px; + cursor: pointer; + color: var(--on-dark-muted); + font-size: var(--fs-sm); + white-space: nowrap; +} +.site-colleges .colleges-toggle:hover { color: var(--on-dark); } +.site-colleges .colleges-arrow { + font-style: normal; + font-size: 11px; + transition: transform .18s ease; +} +.site-colleges:hover .colleges-arrow { transform: rotate(180deg); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: var(--sp-1) 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--accent); + border-radius: 0 0 var(--r-md) var(--r-md); + box-shadow: var(--shadow-md); + z-index: var(--z-dropdown); +} +.site-colleges:hover .colleges-menu, .site-colleges:focus-within .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: var(--sp-2) var(--sp-4); + color: var(--text); + white-space: nowrap; + font-size: var(--fs-base); +} +.site-colleges .colleges-menu li a:hover { background: var(--surface-variant); color: var(--primary); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } + +/* 品牌行:校徽 + 站名 + 站内搜索 */ +.site-header { background: var(--card-bg); box-shadow: var(--shadow-sm); } +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 20px var(--gutter); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-6); +} +.site-brand { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; } +.site-logo { height: 56px; width: auto; flex: none; } +.site-name { + font-size: var(--fs-brand); + font-weight: 700; + color: var(--primary-deep); + letter-spacing: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.site-search { display: flex; flex: none; } +.site-search input { + border: 1px solid var(--border-strong); + border-right: none; + border-radius: var(--r-sm) 0 0 var(--r-sm); + padding: var(--sp-2) var(--sp-3); + width: 260px; + font-size: var(--fs-base); + color: var(--text); + background: var(--card-bg); + outline: none; + transition: border-color .18s ease, box-shadow .18s ease; +} +.site-search input::placeholder { color: var(--text-muted); } +.site-search input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 4px var(--shadow-color); +} +.site-search button { + border: none; + background: var(--primary); + color: var(--on-dark); + padding: var(--sp-2) var(--sp-6); + font-size: var(--fs-base); + border-radius: 0 var(--r-sm) var(--r-sm) 0; + cursor: pointer; + transition: background .18s ease; +} +.site-search button:hover { background: var(--primary-dark); } + +/* 主导航(蓝底 + 金色当前项下杠) */ +.site-nav { background: var(--primary); box-shadow: var(--shadow-sm); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 var(--gutter); + list-style: none; + display: flex; + flex-wrap: wrap; +} +.site-nav > ul > li { position: relative; } +.site-nav li a { + display: block; + padding: var(--sp-3) var(--sp-6); + color: var(--on-dark); + font-size: var(--fs-md); + position: relative; + transition: background .18s ease; +} +.site-nav li a::after { + content: ""; + position: absolute; + left: var(--sp-6); + right: var(--sp-6); + bottom: 0; + height: 3px; + background: var(--accent); + border-radius: 2px 2px 0 0; + transform: scaleX(0); + transition: transform .18s ease; +} +.site-nav li:hover > a, .site-nav li.active > a { background: var(--primary-dark); } +.site-nav li.active > a::after, .site-nav li:hover > a::after { transform: scaleX(1); } + +/* 二级下拉(白底面板) */ +.site-nav li.has-sub > a .caret { + font-style: normal; + font-size: 11px; + margin-left: var(--sp-1); + opacity: .8; +} +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 176px; + margin: 0; + padding: var(--sp-1) 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--accent); + border-radius: 0 0 var(--r-md) var(--r-md); + box-shadow: var(--shadow-md); + z-index: var(--z-dropdown); +} +.site-nav li:hover > ul.sub-nav, .site-nav li:focus-within > ul.sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: var(--sp-2) var(--sp-4); + color: var(--text); + white-space: nowrap; + background: none; + font-size: var(--fs-base); +} +.site-nav .sub-nav li a::after { display: none; } +.site-nav .sub-nav li a:hover { background: var(--surface-variant); color: var(--primary); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播(通栏 1920×560)===== */ +.banner { position: relative; overflow: hidden; background: var(--primary-deep); } +.banner-slides { position: relative; aspect-ratio: 1920 / 560; max-height: 560px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; display: block; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: var(--sp-12) var(--gutter) var(--sp-4); + max-width: var(--max-width); + margin: 0 auto; + color: var(--on-dark); + font-size: var(--fs-xl); + font-weight: 600; + text-shadow: 0 1px 4px var(--scrim); + background: linear-gradient(transparent, var(--scrim)); +} +.banner-dots { + position: absolute; + bottom: var(--sp-4); + right: max(var(--gutter), calc((100% - var(--max-width)) / 2 + var(--gutter))); + display: flex; + gap: var(--sp-2); + z-index: 20; +} +.banner-dots button { + width: 10px; height: 10px; + padding: 0; + border-radius: 999px; + border: none; + background: var(--on-dark-faint); + cursor: pointer; + transition: width .25s ease, background .25s ease; +} +.banner-dots button.active { width: 28px; background: var(--on-dark); } +.banner-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 44px; height: 44px; + border: none; + border-radius: 50%; + background: var(--overlay-dark); + color: var(--on-dark); + font-size: 22px; + line-height: 1; + cursor: pointer; + opacity: .55; + transition: opacity .2s ease, background .2s ease; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.banner-arrow:hover { opacity: 1; background: var(--primary-dark); } +.banner-arrow.prev { left: max(var(--gutter), calc((100% - var(--max-width)) / 2)); } +.banner-arrow.next { right: max(var(--gutter), calc((100% - var(--max-width)) / 2)); } + +/* ===== 通用容器 ===== */ +.home-main, .list-main, .detail-main { display: block; } +.container { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 var(--gutter); +} +.section { margin: var(--sp-6) 0; } +.empty { + color: var(--text-muted); + text-align: center; + padding: var(--sp-12) var(--sp-4); + font-size: var(--fs-md); +} + +/* 空状态插画(design-system §8:与正常态同等用心) */ +.empty-state { + text-align: center; + padding: var(--sp-12) var(--sp-4); + color: var(--text-muted); +} +.empty-state::before { + content: ""; + display: block; + width: 72px; + height: 54px; + margin: 0 auto var(--sp-4); + border: 2px solid var(--border-strong); + border-radius: var(--r-md); + background: + linear-gradient(var(--surface-variant) 60%, transparent 60%) 12px 10px / 40px 6px no-repeat, + linear-gradient(var(--surface-variant) 62%, transparent 62%) 12px 22px / 28px 6px no-repeat, + linear-gradient(var(--border) 100%, transparent 100%) 0 38px / 64px 2px no-repeat; +} +.empty-state .empty-hint { font-size: var(--fs-sm); margin-top: var(--sp-1); } + +/* ===== 首页:头条区 ===== */ +.headline-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: var(--sp-6); + align-items: stretch; +} +.headline-card, .focus-card, .category-block { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--r-md); + box-shadow: var(--shadow-sm); + padding: var(--sp-6); +} +/* 图片新闻卡内容纵向撑满:小图行贴底,与左卡等高时无底部空洞 */ +.focus-card { display: flex; flex-direction: column; } +.focus-card .focus-rows { margin-top: auto; padding-top: var(--sp-3); } +.block-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-4); + margin-bottom: var(--sp-4); + padding-bottom: var(--sp-3); + border-bottom: 1px solid var(--border); +} +.block-head h2, .section-title { + font-size: var(--fs-xl); + color: var(--text); + position: relative; + padding-left: var(--sp-3); +} +.block-head h2::before, .section-title::before { + content: ""; + position: absolute; + left: 0; + top: 6px; + bottom: 6px; + width: 4px; + border-radius: 2px; + background: linear-gradient(var(--primary), var(--primary-dark)); +} +.more { font-size: var(--fs-sm); color: var(--text-muted); white-space: nowrap; } +.more:hover { color: var(--primary); } + +/* 头条列表 */ +.headline-list { list-style: none; } +.headline-lead { padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); } +.headline-lead .lead-title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: var(--fs-2xl); + font-weight: 700; + line-height: 1.4; + color: var(--primary-deep); + margin-bottom: var(--sp-2); +} +.headline-lead .lead-title:hover { color: var(--primary); } +.headline-lead .lead-meta { display: flex; gap: var(--sp-3); align-items: center; } +.headline-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-2); + margin: 0 calc(-1 * var(--sp-2)); + border-bottom: 1px solid var(--border); + border-radius: var(--r-sm); + transition: background .15s ease; +} +.headline-list li:last-child { border-bottom: none; } +.headline-list li:hover { background: var(--surface-variant); } +.headline-list li a { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--fs-md); +} +.top-badge { + flex: none; + color: var(--on-dark); + background: var(--accent); + font-size: var(--fs-xs); + line-height: 1; + padding: 3px var(--sp-2); + border-radius: var(--r-sm); + font-weight: 600; +} + +/* 图片新闻卡(右侧) */ +.focus-card .focus-item { display: block; } +.focus-card .focus-cover { + display: block; + border-radius: var(--r-md); + overflow: hidden; + aspect-ratio: 16 / 9; + margin-bottom: var(--sp-3); + background: var(--surface-variant); +} +.focus-card .focus-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.focus-card .focus-title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: var(--fs-md); + font-weight: 600; + color: var(--text); + line-height: 1.5; +} +.focus-card .focus-item:hover .focus-title { color: var(--primary); } +.focus-rows { list-style: none; margin-top: var(--sp-4); border-top: 1px solid var(--border); } +.focus-rows li { border-bottom: 1px solid var(--border); } +.focus-rows li:last-child { border-bottom: none; } +.focus-rows a { display: flex; gap: var(--sp-3); align-items: center; padding: var(--sp-3) 0; } +.focus-rows .row-cover { + flex: none; + display: block; + width: 96px; + aspect-ratio: 16 / 9; + border-radius: var(--r-sm); + overflow: hidden; + background: var(--surface-variant); +} +.focus-rows .row-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.focus-rows .row-title { + flex: 1; + min-width: 0; + font-size: var(--fs-base); + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.focus-rows a:hover .row-title { color: var(--primary); } + +/* ===== 首页:栏目区块 ===== */ +.blocks-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-6); +} +.block-list { list-style: none; } +.block-featured { padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); } +.block-featured > a { display: flex; gap: var(--sp-4); align-items: flex-start; } +.block-featured .feat-cover { + flex: none; + display: block; + width: 168px; + aspect-ratio: 16 / 9; + border-radius: var(--r-sm); + overflow: hidden; + background: var(--surface-variant); +} +.block-featured .feat-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.block-featured .feat-body { min-width: 0; flex: 1; display: flex; flex-direction: column; } +.block-featured .feat-title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: var(--fs-md); + font-weight: 600; + color: var(--text); + line-height: 1.5; +} +.block-featured .feat-summary { + color: var(--text-light); + font-size: var(--fs-sm); + line-height: 1.6; + margin-top: var(--sp-2); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.block-featured .feat-meta { margin-top: auto; padding-top: var(--sp-2); } +.block-featured a:hover .feat-title { color: var(--primary); } +.block-list li.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-2); + margin: 0 calc(-1 * var(--sp-2)); + border-bottom: 1px solid var(--border); + border-radius: var(--r-sm); + transition: background .15s ease; +} +.block-list li.item:last-child { border-bottom: none; } +.block-list li.item:hover { background: var(--surface-variant); } +.block-list li.item a { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; + padding-left: var(--sp-3); +} +.block-list li.item a::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + width: 5px; + height: 5px; + margin-top: -2.5px; + border-radius: 50%; + background: var(--border-strong); + transition: background .15s ease; +} +.block-list li.item:hover a::before { background: var(--primary); } + +/* ===== 列表页 ===== */ +.list-main { margin: var(--sp-6) auto; } +.page-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--r-md); + box-shadow: var(--shadow-sm); + padding: var(--sp-8) var(--sp-6); +} +.breadcrumb { + color: var(--text-muted); + font-size: var(--fs-sm); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2); + margin-bottom: var(--sp-4); +} +.breadcrumb a { color: var(--text-muted); } +.breadcrumb a:hover { color: var(--primary); } +.breadcrumb .crumb-sep { color: var(--border-strong); } +.breadcrumb .current { color: var(--text-light); font-weight: 500; } +.page-title { + font-size: var(--fs-2xl); + color: var(--text); + position: relative; + padding-left: var(--sp-3); + margin: 0 0 var(--sp-6); +} +.page-title::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 4px; + border-radius: 2px; + background: linear-gradient(var(--primary), var(--primary-dark)); +} + +.article-list { list-style: none; } +.article-list > li { + padding: var(--sp-4) 0; + border-bottom: 1px solid var(--border); +} +.article-list > li:last-child { border-bottom: none; } +.article-item { display: flex; gap: var(--sp-4); align-items: flex-start; } +.article-item .item-cover { + flex: none; + width: 168px; + aspect-ratio: 16 / 9; + border-radius: var(--r-sm); + overflow: hidden; + background: var(--surface-variant); +} +.article-item .item-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.article-item .item-body { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: var(--sp-1); } +.article-item.plain { padding: var(--sp-1) 0; align-items: baseline; } +.article-item.plain .item-title { font-size: var(--fs-lg); } +.item-title { font-size: var(--fs-lg); font-weight: 500; line-height: 1.5; } +.item-title a:hover { color: var(--primary); } +.item-summary { + color: var(--text-light); + font-size: var(--fs-base); + line-height: 1.65; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.item-meta { display: flex; gap: var(--sp-3); align-items: center; margin-top: var(--sp-1); } +.article-item .item-meta { margin-top: auto; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: var(--sp-2); justify-content: center; align-items: center; padding: var(--sp-8) 0 var(--sp-2); flex-wrap: wrap; } +.pagination a { + min-width: 36px; + text-align: center; + padding: var(--sp-1) var(--sp-3); + border: 1px solid var(--border); + background: var(--card-bg); + border-radius: var(--r-sm); + font-size: var(--fs-base); + color: var(--text-light); + transition: border-color .15s ease, color .15s ease, background .15s ease; +} +.pagination a:hover { border-color: var(--primary); color: var(--primary); background: var(--surface-variant); } +.pagination a.active { background: var(--primary); color: var(--on-dark); border-color: var(--primary); font-weight: 600; } +.pagination a.disabled { opacity: .4; pointer-events: none; } + +/* ===== 详情 / 单页 ===== */ +.detail-main { margin: var(--sp-6) auto; } +.article-title { + margin: var(--sp-2) 0 var(--sp-4); + font-size: var(--fs-3xl); + font-weight: 700; + text-align: center; + line-height: 1.4; +} +.article-meta { + display: flex; + gap: var(--sp-4); + justify-content: center; + flex-wrap: wrap; + color: var(--text-muted); + font-size: var(--fs-sm); + padding: var(--sp-2) 0 var(--sp-4); + border-bottom: 1px solid var(--border); +} +.article-content { + line-height: 1.9; + font-size: 16px; + color: var(--text); + padding-top: var(--sp-6); + word-break: break-word; +} +.article-content p { text-indent: 2em; margin: 0 0 var(--sp-3); } +.article-content p:has(img), .article-content figure p { text-indent: 0; text-align: center; } +.article-content p > img:only-child { display: inline-block; } +.article-content img { height: auto; border-radius: var(--r-sm); } +.article-content figure { margin: var(--sp-4) auto; text-align: center; text-indent: 0; } +.article-content figcaption { font-size: var(--fs-sm); color: var(--text-muted); margin-top: var(--sp-2); } +.article-content h2, .article-content h3 { margin: var(--sp-6) 0 var(--sp-3); } +.article-content h2 { font-size: var(--fs-xl); } +.article-content h3 { font-size: var(--fs-lg); } +.article-content ul, .article-content ol { margin: 0 0 var(--sp-3) 2em; text-indent: 0; } +.article-content li { margin-bottom: var(--sp-1); } +.article-content blockquote { + margin: var(--sp-4) 0; + padding: var(--sp-3) var(--sp-4); + border-left: 4px solid var(--accent); + background: var(--surface-variant); + border-radius: 0 var(--r-sm) var(--r-sm) 0; + color: var(--text-light); + text-indent: 0; +} +.article-content table { + border-collapse: collapse; + width: 100%; + margin: var(--sp-4) 0; + font-size: var(--fs-base); +} +.article-content table td, .article-content table th { + border: 1px solid var(--border-strong); + padding: var(--sp-2) var(--sp-3); +} +.article-content table th { background: var(--surface-variant); font-weight: 600; } +.article-content table tbody tr:nth-child(even) { background: var(--surface-variant); } +.article-content table tbody tr:hover { background: var(--border); } + +/* 上下篇 */ +.article-sibling { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-4); + margin-top: var(--sp-8); + padding-top: var(--sp-6); + border-top: 1px solid var(--border); +} +.article-sibling a { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-3) var(--sp-4); + background: var(--card-bg); + border: 1px solid var(--border-strong); + border-radius: var(--r-md); + box-shadow: var(--shadow-sm); + transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; +} +.article-sibling a:hover { + border-color: var(--primary); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} +.article-sibling a.next { text-align: right; } +.article-sibling .sib-label { font-size: var(--fs-sm); color: var(--text-muted); } +.article-sibling .sib-title { + font-size: var(--fs-base); + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.article-sibling a:hover .sib-title { color: var(--primary); } +.article-sibling .sib-none { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-3) var(--sp-4); + background: var(--surface-variant); + border: 1px dashed var(--border-strong); + border-radius: var(--r-md); + color: var(--text-muted); +} +.article-sibling .sib-none.next { text-align: right; } + +/* ===== 页脚 ===== */ +/* 友链区(浅底) */ +.footer-links { background: var(--surface-variant); border-top: 1px solid var(--border); } +.footer-links-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: var(--sp-4) var(--gutter); + display: flex; + flex-direction: column; + gap: var(--sp-2); +} +.links-group { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2) var(--sp-4); + align-items: baseline; + font-size: var(--fs-sm); +} +.links-group .group-name { + font-weight: 600; + color: var(--primary-deep); + margin-right: var(--sp-2); + position: relative; + padding-left: var(--sp-3); +} +.links-group .group-name::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + width: 4px; + height: 4px; + margin-top: -2px; + border-radius: 50%; + background: var(--accent); +} +.links-group a { color: var(--text-light); } +.links-group a:hover { color: var(--primary); } + +/* 页脚主区(深蓝) */ +.site-footer { margin-top: var(--sp-8); background: var(--primary-deep); color: var(--on-dark-muted); } +.footer-main { + max-width: var(--max-width); + margin: 0 auto; + padding: var(--sp-8) var(--gutter) var(--sp-6); + display: flex; + flex-wrap: wrap; + gap: var(--sp-8); + align-items: flex-start; + justify-content: space-between; +} +.footer-brand { display: flex; align-items: center; gap: var(--sp-3); } +.footer-brand .footer-logo { height: 48px; width: auto; } +.footer-brand .footer-site { font-size: var(--fs-xl); font-weight: 600; color: var(--on-dark); letter-spacing: 1px; } +.footer-info { + text-align: right; + font-size: var(--fs-sm); + line-height: 2; + margin: 0; +} +.footer-info p { margin: 0; } +.footer-info a { color: var(--on-dark-muted); } +.footer-info a:hover { color: var(--on-dark); } + +/* 页脚底条 */ +.footer-bottom { + background: var(--primary-deeper); + border-top: 1px solid var(--on-dark-faint); +} +.footer-bottom-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: var(--sp-3) var(--gutter); + text-align: center; + font-size: var(--fs-sm); + color: var(--on-dark-faint); +} +.footer-bottom-inner p { margin: var(--sp-1) 0; } +.footer-bottom-inner a { color: var(--on-dark-muted); } +.footer-bottom-inner a:hover { color: var(--on-dark); } + +/* ===== 移动端适配 ===== */ +@media (max-width: 1024px) { + .headline-grid { grid-template-columns: 1fr; } + .blocks-grid { grid-template-columns: 1fr; } +} +@media (max-width: 768px) { + :root { --gutter: 16px; } + .topbar-inner { justify-content: center; } + .topbar-side { display: none; } + .header-inner { flex-direction: column; align-items: stretch; gap: var(--sp-3); padding: var(--sp-3) var(--gutter); } + .site-logo { height: 44px; } + .site-name { font-size: var(--fs-xl); } + .site-search { width: 100%; } + .site-search input { flex: 1; width: auto; } + .site-nav ul { overflow-x: auto; flex-wrap: nowrap; } + .site-nav li a { padding: var(--sp-3) var(--sp-4); white-space: nowrap; } + .banner-slides { aspect-ratio: 4 / 3; max-height: none; } + .banner-title { font-size: var(--fs-md); padding: var(--sp-8) var(--sp-4) var(--sp-3); } + .banner-arrow { display: none; } + .page-card, .headline-card, .focus-card, .category-block { padding: var(--sp-4) var(--sp-3); } + .article-item { flex-direction: column; } + .article-item .item-cover { width: 100%; } + .article-sibling { grid-template-columns: 1fr; } + .footer-main { flex-direction: column; gap: var(--sp-4); } + .footer-info { text-align: left; } + .article-title { font-size: var(--fs-2xl); } + /* 触屏无 hover:子菜单静态平铺,不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: var(--primary-dark); + border-radius: 0; + min-width: 0; + padding: 0; + } + .site-nav .sub-nav li a { color: var(--on-dark-muted); padding: var(--sp-2) var(--sp-4) var(--sp-2) var(--sp-8); } + .site-nav .sub-nav li a:hover { background: var(--primary-deep); color: var(--on-dark); } +} + +/* ===== 打印样式(公告高频打印场景)===== */ +@media print { + .site-header, .topbar, .site-nav, .banner, .footer-links, .site-footer, + .article-sibling, .pagination, .skip-link, .site-search, .site-colleges { display: none !important; } + body { background: var(--white); color: var(--print-ink); font-size: 12pt; } + .page-card, .detail-main .page-card { border: none; box-shadow: none; padding: 0; margin: 0; max-width: 100%; } + .article-title { font-size: 20pt; } + .article-content { font-size: 11.5pt; line-height: 1.8; } + .article-content p { text-indent: 2em; } + .article-content table th { background: var(--surface-variant) !important; } + a { color: inherit; } +} diff --git a/src/main/resources/templates/themes/default/assets/favicon.png b/src/main/resources/templates/themes/default/assets/favicon.png new file mode 100644 index 0000000..822653c Binary files /dev/null and b/src/main/resources/templates/themes/default/assets/favicon.png differ diff --git a/src/main/resources/templates/themes/default/assets/favicon.svg b/src/main/resources/templates/themes/default/assets/favicon.svg new file mode 100644 index 0000000..336fdce --- /dev/null +++ b/src/main/resources/templates/themes/default/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/themes/default/assets/js/main.js b/src/main/resources/templates/themes/default/assets/js/main.js new file mode 100644 index 0000000..4a5751d --- /dev/null +++ b/src/main/resources/templates/themes/default/assets/js/main.js @@ -0,0 +1,87 @@ +/* default 皮肤包脚本:顶条日期 + 首页轮播(自动播放/圆点/箭头/悬停暂停/键盘),无任何外部依赖 */ +(function () { + /* 顶条日期(本地时间,YYYY年M月D日 星期X) */ + var dateEl = document.getElementById('siteDate'); + if (dateEl) { + var now = new Date(); + var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()]; + dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + + now.getDate() + '日 星期' + week; + } + + /* 轮播 */ + var root = document.querySelector('.banner'); + var slides = document.querySelectorAll('.banner-slide'); + var dots = document.querySelectorAll('.banner-dots button'); + var prevBtn = document.querySelector('.banner-arrow.prev'); + var nextBtn = document.querySelector('.banner-arrow.next'); + if (!slides.length || !root) { + return; + } + var current = 0; + var timer = null; + var INTERVAL = 5000; + + function show(index) { + slides[current].classList.remove('active'); + if (dots[current]) { + dots[current].classList.remove('active'); + } + current = (index + slides.length) % slides.length; + slides[current].classList.add('active'); + if (dots[current]) { + dots[current].classList.add('active'); + } + } + + function start() { + stop(); + if (slides.length > 1) { + timer = window.setInterval(function () { + show(current + 1); + }, INTERVAL); + } + } + + function stop() { + if (timer) { + window.clearInterval(timer); + timer = null; + } + } + + dots.forEach(function (dot, index) { + dot.addEventListener('click', function () { + show(index); + start(); + }); + }); + if (prevBtn) { + prevBtn.addEventListener('click', function () { + show(current - 1); + start(); + }); + } + if (nextBtn) { + nextBtn.addEventListener('click', function () { + show(current + 1); + start(); + }); + } + root.addEventListener('mouseenter', stop); + root.addEventListener('mouseleave', start); + root.addEventListener('focusin', stop); + root.addEventListener('focusout', start); + root.setAttribute('tabindex', '-1'); + root.addEventListener('keydown', function (e) { + if (e.key === 'ArrowLeft') { + show(current - 1); + start(); + } else if (e.key === 'ArrowRight') { + show(current + 1); + start(); + } + }); + + start(); +})(); diff --git a/src/main/resources/templates/themes/default/assets/screenshot.png b/src/main/resources/templates/themes/default/assets/screenshot.png new file mode 100644 index 0000000..efcd53c Binary files /dev/null and b/src/main/resources/templates/themes/default/assets/screenshot.png differ diff --git a/src/main/resources/templates/themes/default/detail.html b/src/main/resources/templates/themes/default/detail.html new file mode 100644 index 0000000..45cf879 --- /dev/null +++ b/src/main/resources/templates/themes/default/detail.html @@ -0,0 +1,60 @@ + + + + +
+ +
+
+ + +
+

文章标题

+ + +
正文
+
+ + +
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/default/fragments/footer.html b/src/main/resources/templates/themes/default/fragments/footer.html new file mode 100644 index 0000000..f8bec1c --- /dev/null +++ b/src/main/resources/templates/themes/default/fragments/footer.html @@ -0,0 +1,39 @@ + + + +
+ + + + + + + + +
+ diff --git a/src/main/resources/templates/themes/default/fragments/head.html b/src/main/resources/templates/themes/default/fragments/head.html new file mode 100644 index 0000000..9fcb6b4 --- /dev/null +++ b/src/main/resources/templates/themes/default/fragments/head.html @@ -0,0 +1,22 @@ + + + + + + + 页面标题 + + + + + + + + + + + + + diff --git a/src/main/resources/templates/themes/default/fragments/header.html b/src/main/resources/templates/themes/default/fragments/header.html new file mode 100644 index 0000000..f6aca08 --- /dev/null +++ b/src/main/resources/templates/themes/default/fragments/header.html @@ -0,0 +1,66 @@ + + + + + diff --git a/src/main/resources/templates/themes/default/home.html b/src/main/resources/templates/themes/default/home.html new file mode 100644 index 0000000..92a17b6 --- /dev/null +++ b/src/main/resources/templates/themes/default/home.html @@ -0,0 +1,118 @@ + + + + +
+ +
+ + + + +
+
+
+
+

头条推荐

+
+ +
+ +
+
+ + +
+
+
+
+

栏目名

+ 更多 > +
+ +
+

栏目暂无内容

+

内容发布后将在此展示

+
+
+
+
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/default/list.html b/src/main/resources/templates/themes/default/list.html new file mode 100644 index 0000000..0b0c014 --- /dev/null +++ b/src/main/resources/templates/themes/default/list.html @@ -0,0 +1,85 @@ + + + + +
+ +
+
+ + + +

栏目名

+

搜索词

+ + +
+

未找到相关内容

+

栏目暂无内容

+

换个关键词试试

+

内容发布后将在此展示

+
+ + + +
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/default/single.html b/src/main/resources/templates/themes/default/single.html new file mode 100644 index 0000000..d1e3003 --- /dev/null +++ b/src/main/resources/templates/themes/default/single.html @@ -0,0 +1,31 @@ + + + + +
+ +
+
+ + +
+

栏目名

+ +
单页内容
+
+

页面暂无内容

+

内容编辑后将在此展示

+
+
+
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/default/theme.json b/src/main/resources/templates/themes/default/theme.json new file mode 100644 index 0000000..170ff05 --- /dev/null +++ b/src/main/resources/templates/themes/default/theme.json @@ -0,0 +1,19 @@ +{ + "specVersion": 1, + "name": "default", + "title": "高校蓝(默认)", + "version": "1.0.0", + "author": "yunhai", + "description": "高校蓝主题:顶栏 + 通栏轮播布局,现行基准皮肤(方案 §7)。", + "screenshot": "assets/screenshot.png", + "bannerSize": "1920x560", + "params": [ + { "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#1a5cb0" }, + { "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深", "type": "color", "default": "#12447f" }, + { "key": "text", "cssVar": "--text", "label": "正文文字色", "type": "color", "default": "#333333" }, + { "key": "textLight", "cssVar": "--text-light", "label": "次要文字色", "type": "color", "default": "#777777" }, + { "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#f5f6f8" }, + { "key": "cardBg", "cssVar": "--card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" }, + { "key": "border", "cssVar": "--border", "label": "边框色", "type": "color", "default": "#e5e6eb" } + ] +} diff --git a/src/main/resources/templates/themes/scholar-blue/assets/css/main.css b/src/main/resources/templates/themes/scholar-blue/assets/css/main.css new file mode 100644 index 0000000..1912e88 --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/assets/css/main.css @@ -0,0 +1,1295 @@ +/* ===================================================================== + scholar-blue · 学院蓝主题(参考语言:经典学院派门户) + 设计语言:深蓝顶栏/品牌行 + 白底主导航(吸顶反蓝) + 主蓝半透明下拉面板 + + 居中双线栏目标题 + 日期方块 + 深蓝圆环纹理页脚 + 色彩令牌全部走 CSS 变量,theme.json color 参数经 :root 覆盖真实生效。 + ===================================================================== */ + +/* --------------------------------------------------------------------- + 1. 设计令牌(与 theme.json params 一一对应,禁止出现未消费的声明) + --------------------------------------------------------------------- */ +:root { + --sp-primary: #0a5aa8; /* 主色:顶栏/品牌行/标题/强调 */ + --sp-primary-dark: #033d70; /* 主色加深:hover/渐变端/页脚深端 */ + --sp-primary-light: #1167bb; /* 主色提亮:亮线条/hover 链接/角标 */ + --sp-nav-bg: #ffffff; /* 主导航条底色(白条学院风) */ + --sp-footer-bg: #0b4781; /* 页脚主底 */ + --sp-text: #333333; /* 正文 */ + --sp-text-light: #777777; /* 次要文字 */ + --sp-bg: #eff2f6; /* 页面底色 */ + --sp-card-bg: #ffffff; /* 卡片底 */ + --sp-border: #e3e7ee; /* 边框/分隔线 */ + + --sp-ink-on-dark: #ffffff; /* 深底主文字 */ + --sp-ink-on-dark-soft: rgba(255,255,255,.72); /* 深底次要文字 */ + --sp-ink-on-dark-faint: rgba(255,255,255,.45); + --sp-panel: rgba(10,90,168,.94); /* 下拉面板(主色半透明) */ + --sp-shadow-sm: 0 1px 4px rgba(3,61,112,.08); + --sp-shadow-md: 0 6px 18px rgba(3,61,112,.12); + --sp-shadow-lg: 0 14px 34px rgba(3,61,112,.18); + --sp-radius: 6px; + --sp-radius-lg: 10px; + --sp-speed: .28s; + --sp-font: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Helvetica Neue", Arial, sans-serif; +} + +/* --------------------------------------------------------------------- + 2. Reset 与基础 + --------------------------------------------------------------------- */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + font-family: var(--sp-font); + font-size: 14px; + line-height: 1.7; + color: var(--sp-text); + background: var(--sp-bg); + word-break: break-word; +} + +ul, ol { list-style: none; } + +a { color: var(--sp-primary); text-decoration: none; transition: color var(--sp-speed) ease; } +a:hover { color: var(--sp-primary-light); } + +img { max-width: 100%; display: block; border: 0; } + +button { font-family: inherit; cursor: pointer; } + +input, button, select, textarea { font-size: inherit; color: inherit; } + +::selection { background: var(--sp-primary); color: #fff; } + +/* 跳转链接:仅键盘聚焦时可见 */ +.skip-link { + position: absolute; left: -999px; top: 0; z-index: 300; + background: var(--sp-primary-dark); color: #fff; + padding: 8px 18px; border-radius: 0 0 var(--sp-radius) 0; +} +.skip-link:focus { left: 0; color: #fff; } + +/* 通用容器 */ +.sp-container { + width: 1200px; max-width: calc(100% - 40px); + margin: 0 auto; +} + +/* 屏幕阅读器专用 */ +.sp-visually-hidden { + position: absolute; width: 1px; height: 1px; + margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; +} + +/* 置顶徽章 */ +.sp-top-badge { + display: inline-block; flex: none; + padding: 0 7px; line-height: 18px; + font-size: 12px; color: #fff; + background: var(--sp-primary-light); + border-radius: 2px; + letter-spacing: 1px; +} + +/* 空状态 */ +.sp-empty { + padding: 56px 20px; + text-align: center; + color: var(--sp-text-light); +} +.sp-empty p:first-child { font-size: 16px; margin-bottom: 8px; } +.sp-empty-hint { font-size: 13px; color: var(--sp-text-light); opacity: .75; } + +/* --------------------------------------------------------------------- + 3. 顶栏(深蓝细条:日期 + 院系切换) + --------------------------------------------------------------------- */ +.sp-topbar { + background: linear-gradient(90deg, var(--sp-primary-dark), var(--sp-primary)); + color: var(--sp-ink-on-dark-soft); + font-size: 13px; +} + +.sp-topbar-inner { + width: 1200px; max-width: calc(100% - 40px); + margin: 0 auto; + display: flex; align-items: center; justify-content: space-between; + min-height: 36px; +} + +.sp-topbar-date { letter-spacing: .5px; } + +.sp-topbar-side { position: relative; } + +/* 院系切换:细字 + 箭头,展开为深蓝面板 */ +.sp-colleges { position: relative; } + +.sp-colleges-toggle { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 2px; + cursor: pointer; +} +.sp-colleges-toggle a { color: var(--sp-ink-on-dark-soft); } +.sp-colleges-toggle a:hover { color: #fff; } + +.sp-colleges-arrow { + font-style: normal; font-size: 10px; + transition: transform var(--sp-speed) ease; +} +.sp-colleges:hover .sp-colleges-arrow { transform: rotate(180deg); } + +.sp-colleges-menu { + position: absolute; right: 0; top: calc(100% + 6px); + min-width: 180px; max-height: 280px; overflow: auto; + background: var(--sp-panel); + backdrop-filter: blur(4px); + border-top: 2px solid var(--sp-primary-light); + border-radius: 0 0 var(--sp-radius) var(--sp-radius); + box-shadow: var(--sp-shadow-lg); + padding: 6px 0; + opacity: 0; visibility: hidden; transform: translateY(-6px); + transition: opacity var(--sp-speed) ease, transform var(--sp-speed) ease, visibility var(--sp-speed); + z-index: 120; +} +.sp-colleges:hover .sp-colleges-menu, +.sp-colleges:focus-within .sp-colleges-menu { + opacity: 1; visibility: visible; transform: translateY(0); +} + +.sp-colleges-menu li a { + display: block; + padding: 7px 16px; + color: var(--sp-ink-on-dark); + font-size: 13px; + border-left: 2px solid transparent; + transition: background var(--sp-speed) ease, border-color var(--sp-speed) ease, padding-left var(--sp-speed) ease; +} +.sp-colleges-menu li a:hover { + background: var(--sp-primary-dark); + border-left-color: var(--sp-primary-light); + padding-left: 20px; + color: #fff; +} +.sp-colleges-menu li.active a { + background: rgba(255,255,255,.08); + border-left-color: var(--sp-primary-light); + color: #fff; +} + +/* --------------------------------------------------------------------- + 4. 品牌行(深蓝主区:徽标 + 站名 + 圆角搜索) + --------------------------------------------------------------------- */ +.sp-brandbar { + background: linear-gradient(100deg, var(--sp-primary-dark) 0%, var(--sp-primary) 62%, var(--sp-primary-light) 130%); + color: var(--sp-ink-on-dark); + position: relative; + overflow: hidden; +} +/* 品牌行右侧淡圆环装饰(CSS 绘制,无图片依赖) */ +.sp-brandbar::before, +.sp-brandbar::after { + content: ""; + position: absolute; + border-radius: 50%; + border: 26px solid rgba(255,255,255,.05); + pointer-events: none; +} +.sp-brandbar::before { width: 320px; height: 320px; right: -80px; top: -170px; } +.sp-brandbar::after { width: 200px; height: 200px; right: 180px; bottom: -140px; border-width: 16px; } + +.sp-brandbar-inner { + width: 1200px; max-width: calc(100% - 40px); + margin: 0 auto; + display: flex; align-items: center; justify-content: space-between; + gap: 24px; + padding: 22px 0; + position: relative; z-index: 1; +} + +.sp-brand { + display: flex; align-items: center; gap: 14px; + min-width: 0; +} + +/* 徽标容器:白衬圆角方块,校徽任何底色都干净 */ +.sp-brand-badge { + flex: none; + width: 58px; height: 58px; + display: inline-flex; align-items: center; justify-content: center; + background: rgba(255,255,255,.94); + border-radius: var(--sp-radius); + box-shadow: 0 2px 8px rgba(3,61,112,.35); + overflow: hidden; +} +.sp-brand-badge--plain { color: var(--sp-primary); } +.sp-logo { width: 100%; height: 100%; object-fit: contain; } + +.sp-site-name { + color: var(--sp-ink-on-dark); + font-size: 27px; + font-weight: 700; + letter-spacing: 2px; + line-height: 1.3; + text-shadow: 0 2px 6px rgba(3,61,112,.4); + white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; + max-width: 560px; +} +.sp-site-name:hover { color: #fff; } + +/* 搜索:白色胶囊(学院风记忆点) */ +.sp-search { + flex: none; + display: flex; align-items: center; + width: 300px; max-width: 36%; + background: #fff; + border-radius: 999px; + padding: 3px 4px 3px 18px; + box-shadow: 0 3px 10px rgba(3,61,112,.28); +} +.sp-search input { + flex: 1; min-width: 0; + border: 0; outline: none; + background: transparent; + font-size: 13px; color: var(--sp-text); + line-height: 30px; +} +.sp-search input::placeholder { color: #a9b3c0; } +.sp-search button { + flex: none; + width: 32px; height: 32px; + border: 0; border-radius: 50%; + background: var(--sp-primary); + color: #fff; + display: inline-flex; align-items: center; justify-content: center; + transition: background var(--sp-speed) ease, transform var(--sp-speed) ease; +} +.sp-search button:hover { background: var(--sp-primary-light); transform: scale(1.06); } + +/* --------------------------------------------------------------------- + 5. 主导航(白底条 + 主蓝半透明下拉面板 + 吸顶反蓝) + --------------------------------------------------------------------- */ +.sp-nav { + background: var(--sp-nav-bg); + box-shadow: var(--sp-shadow-sm); + position: relative; + z-index: 100; +} +/* 顶部主蓝细线:把导航条与品牌行缝在一起 */ +.sp-nav::before { + content: ""; + display: block; height: 3px; + background: linear-gradient(90deg, var(--sp-primary), var(--sp-primary-light)); +} + +.sp-nav-inner { + width: 1200px; max-width: calc(100% - 40px); + margin: 0 auto; + position: relative; +} + +.sp-nav-toggle { display: none; } + +.sp-nav-list { + display: flex; + justify-content: space-between; +} + +.sp-nav-list > li { position: relative; flex: 1 1 auto; text-align: center; } + +.sp-nav-list > li > a { + display: flex; align-items: center; justify-content: center; gap: 5px; + position: relative; + padding: 0 10px; + height: 52px; + font-size: 16px; + font-weight: 600; + letter-spacing: 1px; + color: var(--sp-primary-dark); + white-space: nowrap; + transition: color var(--sp-speed) ease, background var(--sp-speed) ease; +} +/* 底部指示条:hover/当前项 */ +.sp-nav-list > li > a::after { + content: ""; + position: absolute; left: 50%; bottom: 0; + width: 0; height: 3px; + background: var(--sp-primary); + transform: translateX(-50%); + transition: width var(--sp-speed) ease; +} +.sp-nav-list > li:hover > a, +.sp-nav-list > li.active > a { + color: var(--sp-primary); + background: rgba(10,90,168,.06); +} +.sp-nav-list > li:hover > a::after, +.sp-nav-list > li.active > a::after { width: 56%; } + +.sp-caret { + font-style: normal; font-size: 11px; + opacity: .65; + transition: transform var(--sp-speed) ease; +} +.sp-nav-list > li:hover .sp-caret { transform: rotate(180deg); } + +/* —— 下拉面板:主蓝半透明、顶部亮线、项 hover 加深 + 左亮条(红线第一优先级) —— */ +.sp-sub { + position: absolute; left: 50%; top: 100%; + transform: translate(-50%, 10px); + min-width: 176px; + background: var(--sp-panel); + backdrop-filter: blur(6px); + border-top: 3px solid var(--sp-primary-light); + border-radius: 0 0 var(--sp-radius) var(--sp-radius); + box-shadow: var(--sp-shadow-lg); + padding: 6px 0; + text-align: left; + opacity: 0; visibility: hidden; + transition: opacity var(--sp-speed) ease, transform var(--sp-speed) ease, visibility var(--sp-speed); + z-index: 110; +} +.sp-nav-list > li:hover .sp-sub, +.sp-nav-list > li:focus-within .sp-sub { + opacity: 1; visibility: visible; + transform: translate(-50%, 0); +} + +/* 面板小尖角 */ +.sp-sub::before { + content: ""; + position: absolute; left: 50%; top: -8px; + width: 12px; height: 12px; + background: var(--sp-primary); + transform: translateX(-50%) rotate(45deg); + border-radius: 2px 0 0 0; +} + +.sp-sub li a { + display: block; + position: relative; + margin: 2px 8px; + padding: 9px 14px 9px 22px; + font-size: 14px; + color: var(--sp-ink-on-dark); + border-radius: 4px; + white-space: nowrap; + transition: background var(--sp-speed) ease, padding-left var(--sp-speed) ease, color var(--sp-speed) ease; +} +/* 项前细刻度线 */ +.sp-sub li a::before { + content: ""; + position: absolute; left: 10px; top: 50%; + width: 4px; height: 4px; + margin-top: -2px; + background: rgba(255,255,255,.5); + border-radius: 50%; + transition: background var(--sp-speed) ease, box-shadow var(--sp-speed) ease; +} +.sp-sub li a:hover { + background: var(--sp-primary-dark); + padding-left: 26px; + color: #fff; +} +.sp-sub li a:hover::before { + background: var(--sp-primary-light); + box-shadow: 0 0 6px var(--sp-primary-light); +} + +/* —— 吸顶反蓝(.sp-nav--fixed 由 main.js 滚动加挂) —— */ +.sp-nav--fixed { + position: fixed; left: 0; right: 0; top: 0; + background: linear-gradient(90deg, var(--sp-primary-dark), var(--sp-primary)); + box-shadow: var(--sp-shadow-md); + animation: sp-nav-drop var(--sp-speed) ease; +} +@keyframes sp-nav-drop { + from { transform: translateY(-100%); } + to { transform: translateY(0); } +} +.sp-nav--fixed::before { height: 0; } +.sp-nav--fixed .sp-nav-list > li > a { color: var(--sp-ink-on-dark); } +.sp-nav--fixed .sp-nav-list > li:hover > a, +.sp-nav--fixed .sp-nav-list > li.active > a { + color: #fff; + background: rgba(255,255,255,.1); +} +.sp-nav--fixed .sp-nav-list > li > a::after { background: var(--sp-primary-light); } +/* 吸顶态下拉:底色更沉,与蓝条衔接 */ +.sp-nav--fixed .sp-sub { background: rgba(3,61,112,.96); } +.sp-nav--fixed .sp-sub::before { background: var(--sp-primary-dark); } + +/* 吸顶占位,防跳变(main.js 加挂时同步高度,解锁即移除) */ +.sp-nav-holder { display: block; } + +/* --------------------------------------------------------------------- + 6. 首页 banner(通栏轮播 + 圆形箭头 + 底部圆点) + --------------------------------------------------------------------- */ +.sp-banner { + position: relative; + overflow: hidden; + background: var(--sp-primary-dark); +} +.sp-banner-slides { position: relative; height: 520px; } + +.sp-banner-slide { + position: absolute; inset: 0; + opacity: 0; visibility: hidden; + transition: opacity .8s ease, visibility .8s ease; +} +.sp-banner-slide.active { opacity: 1; visibility: visible; z-index: 1; } + +.sp-banner-slide img { + width: 100%; height: 100%; + object-fit: cover; +} +/* 底部渐隐遮罩上承标题 */ +.sp-banner-slide::after { + content: ""; + position: absolute; left: 0; right: 0; bottom: 0; + height: 120px; + background: linear-gradient(180deg, transparent, rgba(3,61,112,.66)); + pointer-events: none; +} +.sp-banner-slide.active img { animation: sp-banner-zoom 6.5s ease forwards; } +@keyframes sp-banner-zoom { + from { transform: scale(1); } + to { transform: scale(1.045); } +} + +.sp-banner-title { + position: absolute; left: 6%; bottom: 34px; + z-index: 2; + max-width: 70%; + color: #fff; + font-size: 24px; + font-weight: 600; + letter-spacing: 1px; + text-shadow: 0 2px 8px rgba(3,61,112,.6); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* 圆形半透明箭头(建平语言) */ +.sp-banner-arrow { + position: absolute; top: 50%; z-index: 3; + width: 52px; height: 52px; margin-top: -26px; + border: 2px solid rgba(255,255,255,.75); + border-radius: 50%; + background: rgba(3,61,112,.25); + color: #fff; + font-size: 26px; line-height: 1; + display: inline-flex; align-items: center; justify-content: center; + padding-bottom: 4px; + opacity: 0; + transition: opacity var(--sp-speed) ease, background var(--sp-speed) ease, border-color var(--sp-speed) ease; +} +.sp-banner:hover .sp-banner-arrow { opacity: 1; } +.sp-banner-arrow:hover { background: var(--sp-primary); border-color: #fff; } +.sp-banner-arrow.prev { left: 26px; } +.sp-banner-arrow.next { right: 26px; } + +/* 圆点指示器 */ +.sp-banner-dots { + position: absolute; left: 0; right: 0; bottom: 18px; + z-index: 3; + display: flex; justify-content: center; gap: 10px; +} +.sp-banner-dots button { + width: 10px; height: 10px; + border: 0; border-radius: 50%; + background: rgba(255,255,255,.55); + transition: background var(--sp-speed) ease, transform var(--sp-speed) ease; +} +.sp-banner-dots button:hover { background: #fff; } +.sp-banner-dots button.active { + background: #fff; + transform: scale(1.25); + box-shadow: 0 0 0 3px rgba(255,255,255,.25); +} + +/* --------------------------------------------------------------------- + 7. 区块标题(居中双线 + 下短横)与区块通用 + --------------------------------------------------------------------- */ +.sp-section { margin-top: 40px; } + +.sp-block-head { + position: relative; + text-align: center; + padding: 14px 0 12px; + border-top: 1px solid var(--sp-border); + border-bottom: 1px solid var(--sp-border); +} +.sp-block-head h2 { + display: inline-block; + position: relative; + font-size: 22px; + font-weight: 700; + letter-spacing: 3px; + color: var(--sp-primary-dark); +} +/* 标题下短横:主蓝 3px */ +.sp-block-tick { + position: absolute; left: 50%; bottom: -2px; + width: 42px; height: 3px; + transform: translateX(-50%); + background: var(--sp-primary); + border-radius: 2px; +} + +/* 栏目卡头变体:左右布局(标题居左 + 更多居右) */ +.sp-block-head--bar { + text-align: left; + padding: 0 0 10px; + border-top: 0; + border-bottom: 2px solid var(--sp-primary); + display: flex; align-items: baseline; justify-content: space-between; +} +.sp-block-head--bar h2 { + font-size: 19px; + letter-spacing: 1px; + padding-left: 12px; + position: relative; +} +/* 标题左侧竖条 */ +.sp-block-head--bar h2::before { + content: ""; + position: absolute; left: 0; top: 6px; bottom: 6px; + width: 4px; + background: var(--sp-primary); + border-radius: 2px; +} +.sp-block-head--bar .sp-block-tick { display: none; } + +.sp-more { + font-size: 13px; + font-weight: 400; + color: var(--sp-text-light); + letter-spacing: 0; + transition: color var(--sp-speed) ease, transform var(--sp-speed) ease; +} +.sp-more:hover { color: var(--sp-primary); transform: translateX(3px); } + +/* --------------------------------------------------------------------- + 8. 头条区(左:头条推荐列表 / 右:图片新闻) + --------------------------------------------------------------------- */ +.sp-headline { + display: grid; + grid-template-columns: 7fr 5fr; + gap: 28px; +} +/* grid 项允许收缩到轨道内:否则 nowrap 省略文本会把 auto 最小宽撑破容器(移动端横向溢出) */ +.sp-headline > * { min-width: 0; } + +.sp-headline-card, +.sp-focus-card { + background: var(--sp-card-bg); + border-radius: var(--sp-radius-lg); + box-shadow: var(--sp-shadow-sm); + padding: 6px 22px 18px; +} + +/* 头条推荐 */ +.sp-headline-list li { + display: flex; align-items: center; gap: 10px; + padding: 10px 2px; + border-bottom: 1px dashed var(--sp-border); +} +.sp-headline-list li:last-child { border-bottom: 0; } +.sp-headline-list li a { + flex: 1; min-width: 0; + color: var(--sp-text); + font-size: 15px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition: color var(--sp-speed) ease, padding-left var(--sp-speed) ease; +} +.sp-headline-list li a:hover { color: var(--sp-primary); padding-left: 4px; } +.sp-headline-list time { flex: none; color: var(--sp-text-light); font-size: 13px; } + +/* 头条头条:大标题 + 元信息 */ +.sp-headline-lead { + display: block !important; + padding: 14px 2px 16px !important; +} +.sp-lead-title { + display: block; + font-size: 19px !important; + font-weight: 700; + color: var(--sp-primary-dark) !important; + line-height: 1.5; + white-space: normal !important; + overflow: hidden; + max-height: 3em; +} +.sp-lead-title:hover { color: var(--sp-primary) !important; padding-left: 0 !important; } +.sp-lead-meta { + display: flex; align-items: center; gap: 10px; + margin-top: 8px; + font-size: 13px; color: var(--sp-text-light); +} + +/* 图片新闻 */ +.sp-focus-lead { + display: block; + margin-top: 14px; + border-radius: var(--sp-radius); + overflow: hidden; + position: relative; +} +.sp-focus-cover { display: block; aspect-ratio: 16/9; overflow: hidden; background: var(--sp-bg); } +.sp-focus-cover img { width: 100%; height: 100%; object-fit: cover; transition: transform .5s ease; } +.sp-focus-lead:hover .sp-focus-cover img { transform: scale(1.05); } +.sp-focus-title { + position: absolute; left: 0; right: 0; bottom: 0; + padding: 26px 14px 10px; + color: #fff; font-size: 15px; font-weight: 600; + background: linear-gradient(180deg, transparent, rgba(3,61,112,.82)); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.sp-focus-rows { margin-top: 10px; } +.sp-focus-rows li a { + display: flex; align-items: center; gap: 12px; + padding: 8px 0; +} +.sp-row-cover { + flex: none; + width: 104px; height: 66px; + border-radius: 4px; + overflow: hidden; + background: var(--sp-bg); +} +.sp-row-cover img { width: 100%; height: 100%; object-fit: cover; transition: transform .5s ease; } +.sp-focus-rows li a:hover .sp-row-cover img { transform: scale(1.06); } +.sp-row-title { + flex: 1; min-width: 0; + color: var(--sp-text); + font-size: 14px; + line-height: 1.55; + max-height: 3.1em; + overflow: hidden; + transition: color var(--sp-speed) ease; +} +.sp-focus-rows li a:hover .sp-row-title { color: var(--sp-primary); } + +/* --------------------------------------------------------------------- + 9. 栏目区块(白卡三列 + 日期方块行) + --------------------------------------------------------------------- */ +.sp-blocks { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + margin-top: 14px; +} + +.sp-block { + background: var(--sp-card-bg); + border-radius: var(--sp-radius-lg); + box-shadow: var(--sp-shadow-sm); + padding: 18px 20px 14px; + display: flex; flex-direction: column; + min-width: 0; + transition: box-shadow var(--sp-speed) ease, transform var(--sp-speed) ease; +} +.sp-block:hover { box-shadow: var(--sp-shadow-md); transform: translateY(-2px); } + +.sp-block-list { flex: 1; } + +/* 区块头条(有封面图文 / 无封面纯文) */ +.sp-block-featured { border-bottom: 1px solid var(--sp-border); } +.sp-block-featured > a { + display: block; + padding: 12px 0 14px; +} +.sp-feat-cover { + display: block; + aspect-ratio: 16/9; + border-radius: var(--sp-radius); + overflow: hidden; + background: var(--sp-bg); + margin-bottom: 10px; +} +.sp-feat-cover img { width: 100%; height: 100%; object-fit: cover; transition: transform .5s ease; } +.sp-block-featured > a:hover .sp-feat-cover img { transform: scale(1.04); } + +.sp-feat-title { + display: block; + font-size: 16px; font-weight: 700; + color: var(--sp-primary-dark); + line-height: 1.5; + max-height: 3em; + overflow: hidden; + transition: color var(--sp-speed) ease; +} +.sp-block-featured > a:hover .sp-feat-title { color: var(--sp-primary); } +.sp-feat-summary { + display: block; + margin-top: 6px; + font-size: 13px; color: var(--sp-text-light); + line-height: 1.65; + max-height: 3.3em; + overflow: hidden; +} +.sp-feat-meta { + display: flex; align-items: center; gap: 10px; + margin-top: 8px; + font-size: 12px; color: var(--sp-text-light); +} + +/* 列表行:日期方块 + 标题(建平通知公告语言) */ +.sp-block-item { + display: flex; align-items: center; gap: 12px; + padding: 8px 0; + border-bottom: 1px dashed var(--sp-border); +} +.sp-block-item:last-child { border-bottom: 0; } + +.sp-date-chip { + flex: none; + width: 46px; + text-align: center; + border-radius: 4px; + overflow: hidden; + background: var(--sp-bg); + border: 1px solid var(--sp-border); + transition: background var(--sp-speed) ease, border-color var(--sp-speed) ease; +} +.sp-date-chip b { + display: block; + font-size: 17px; line-height: 24px; + font-weight: 700; + color: var(--sp-primary); + background: rgba(10,90,168,.08); + transition: color var(--sp-speed) ease; +} +.sp-date-chip i { + display: block; + font-style: normal; + font-size: 10px; line-height: 16px; + color: var(--sp-text-light); + transform: scale(.92); +} +.sp-block-item:hover .sp-date-chip { + background: var(--sp-primary); + border-color: var(--sp-primary); +} +.sp-block-item:hover .sp-date-chip b { color: #fff; background: transparent; } +.sp-block-item:hover .sp-date-chip i { color: rgba(255,255,255,.8); } + +.sp-block-item-title { + flex: 1; min-width: 0; + font-size: 14px; + color: var(--sp-text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition: color var(--sp-speed) ease; +} +.sp-block-item:hover .sp-block-item-title { color: var(--sp-primary); } + +/* --------------------------------------------------------------------- + 10. 列表/详情/单页公共:页面卡 + 面包屑 + 页题 + --------------------------------------------------------------------- */ +.sp-page { padding: 28px 0 48px; } + +.sp-page-card { + background: var(--sp-card-bg); + border-radius: var(--sp-radius-lg); + box-shadow: var(--sp-shadow-sm); + padding: 26px 32px 34px; + min-height: 420px; +} + +.sp-crumb { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + font-size: 13px; + color: var(--sp-text-light); + padding-bottom: 14px; + border-bottom: 1px solid var(--sp-border); + margin-bottom: 20px; +} +.sp-crumb a { color: var(--sp-text-light); } +.sp-crumb a:hover { color: var(--sp-primary); } +.sp-crumb-sep { color: #c3cad4; } +.sp-crumb-current { color: var(--sp-primary); } + +.sp-page-title { + position: relative; + font-size: 23px; + font-weight: 700; + letter-spacing: 1px; + color: var(--sp-primary-dark); + padding-left: 16px; + margin-bottom: 22px; +} +.sp-page-title::before { + content: ""; + position: absolute; left: 0; top: 5px; bottom: 5px; + width: 5px; + border-radius: 3px; + background: linear-gradient(180deg, var(--sp-primary), var(--sp-primary-light)); +} + +/* --------------------------------------------------------------------- + 11. 列表页:图文行 + 行式列表 + 分页 + --------------------------------------------------------------------- */ +.sp-article-list > li { border-bottom: 1px solid var(--sp-border); } +.sp-article-list > li:last-child { border-bottom: 0; } + +/* 图文行 */ +.sp-article-row { + display: flex; gap: 20px; + padding: 18px 2px; +} +.sp-item-cover { + flex: none; + width: 216px; aspect-ratio: 3/2; + border-radius: var(--sp-radius); + overflow: hidden; + background: var(--sp-bg); +} +.sp-item-cover img { width: 100%; height: 100%; object-fit: cover; transition: transform .5s ease; } +.sp-article-row:hover .sp-item-cover img { transform: scale(1.05); } + +.sp-item-body { flex: 1; min-width: 0; padding-top: 4px; } +.sp-item-title a { + font-size: 17px; font-weight: 600; + color: var(--sp-text); + line-height: 1.5; + transition: color var(--sp-speed) ease; +} +.sp-item-title a:hover { color: var(--sp-primary); } +.sp-item-summary { + margin-top: 8px; + font-size: 13px; color: var(--sp-text-light); + line-height: 1.7; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.sp-item-meta { + display: flex; align-items: center; gap: 12px; + margin-top: 10px; + font-size: 12px; color: var(--sp-text-light); +} + +/* 行式(无封面):刻度符号 + 标题 + 右侧日期 */ +.sp-article-line { + display: flex; align-items: center; gap: 12px; + padding: 13px 2px; +} +.sp-line-tick { + flex: none; + width: 6px; height: 6px; + background: var(--sp-primary); + transform: rotate(45deg); + opacity: .85; + transition: transform var(--sp-speed) ease; +} +.sp-article-line:hover .sp-line-tick { transform: rotate(45deg) scale(1.35); } + +.sp-line-main { flex: 1; min-width: 0; } +.sp-line-title { + display: block; + font-size: 15px; + color: var(--sp-text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition: color var(--sp-speed) ease, padding-left var(--sp-speed) ease; +} +.sp-article-line:hover .sp-line-title { color: var(--sp-primary); padding-left: 4px; } +.sp-article-line .sp-item-summary { margin-top: 4px; -webkit-line-clamp: 1; } + +.sp-line-side { + flex: none; + display: flex; align-items: center; gap: 10px; + font-size: 13px; color: var(--sp-text-light); +} + +/* 分页 */ +.sp-pagination { + display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; + margin-top: 30px; +} +.sp-pagination a { + min-width: 36px; height: 36px; + display: inline-flex; align-items: center; justify-content: center; + padding: 0 10px; + font-size: 14px; + color: var(--sp-text); + background: var(--sp-bg); + border: 1px solid var(--sp-border); + border-radius: 4px; + transition: all var(--sp-speed) ease; +} +.sp-pagination a:hover { + color: #fff; + background: var(--sp-primary); + border-color: var(--sp-primary); +} +.sp-pagination a.active { + color: #fff; + background: var(--sp-primary); + border-color: var(--sp-primary); + font-weight: 700; + box-shadow: 0 3px 8px rgba(10,90,168,.35); +} +.sp-pagination a.disabled { + opacity: .45; pointer-events: none; +} + +/* --------------------------------------------------------------------- + 12. 详情/单页:文章排版 + --------------------------------------------------------------------- */ +.sp-article-title { + text-align: center; + font-size: 24px; + font-weight: 700; + color: var(--sp-primary-dark); + line-height: 1.5; + padding: 6px 20px 0; +} + +.sp-article-meta { + display: flex; justify-content: center; flex-wrap: wrap; gap: 6px 22px; + padding: 14px 0 18px; + margin-bottom: 24px; + font-size: 13px; color: var(--sp-text-light); + border-bottom: 1px solid var(--sp-border); + position: relative; +} +/* 标题下居中短横(与区块标题语言呼应) */ +.sp-article-meta::before { + content: ""; + position: absolute; left: 50%; top: -12px; + width: 42px; height: 3px; + transform: translateX(-50%); + background: var(--sp-primary); + border-radius: 2px; +} + +.sp-article-content { + font-size: 15px; + line-height: 1.9; + color: var(--sp-text); +} +.sp-article-content p { margin: 0 0 14px; } +.sp-article-content h1, .sp-article-content h2, +.sp-article-content h3, .sp-article-content h4 { + margin: 22px 0 12px; + color: var(--sp-primary-dark); + font-weight: 700; +} +.sp-article-content h2 { font-size: 19px; } +.sp-article-content h3 { font-size: 17px; } +.sp-article-content h4 { font-size: 15px; } +.sp-article-content img { + max-width: 100%; + height: auto; + border-radius: var(--sp-radius); + margin: 6px auto 14px; + box-shadow: var(--sp-shadow-sm); +} +.sp-article-content ul, .sp-article-content ol { + margin: 0 0 14px; padding-left: 26px; +} +.sp-article-content ul li { list-style: disc; } +.sp-article-content ol li { list-style: decimal; } +.sp-article-content li { margin-bottom: 6px; } +.sp-article-content blockquote { + margin: 14px 0; + padding: 12px 18px; + background: rgba(10,90,168,.05); + border-left: 4px solid var(--sp-primary); + border-radius: 0 var(--sp-radius) var(--sp-radius) 0; + color: var(--sp-text-light); +} +.sp-article-content table { + width: 100%; + border-collapse: collapse; + margin: 14px 0; + font-size: 14px; +} +.sp-article-content th, .sp-article-content td { + border: 1px solid var(--sp-border); + padding: 8px 12px; + text-align: left; +} +.sp-article-content th { + background: rgba(10,90,168,.06); + color: var(--sp-primary-dark); + font-weight: 600; +} +.sp-article-content a { color: var(--sp-primary-light); text-decoration: underline; } +.sp-article-content hr { + border: 0; height: 1px; + background: var(--sp-border); + margin: 20px 0; +} +.sp-article-content video, .sp-article-content audio { max-width: 100%; } + +/* 上下篇 */ +.sp-sibling { + display: grid; grid-template-columns: 1fr 1fr; gap: 16px; + margin-top: 34px; + padding-top: 22px; + border-top: 1px solid var(--sp-border); +} +.sp-sib, .sp-sib-none { + display: block; + padding: 12px 16px; + border: 1px solid var(--sp-border); + border-radius: var(--sp-radius); + background: var(--sp-bg); + min-width: 0; + transition: border-color var(--sp-speed) ease, background var(--sp-speed) ease, box-shadow var(--sp-speed) ease; +} +.sp-sib:hover { + border-color: var(--sp-primary); + background: var(--sp-card-bg); + box-shadow: var(--sp-shadow-sm); +} +.sp-sib.next { text-align: right; } +.sp-sib-label { + display: block; + font-size: 12px; + color: var(--sp-primary); + letter-spacing: 2px; + margin-bottom: 4px; +} +.sp-sib-title { + display: block; + font-size: 14px; + color: var(--sp-text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sp-sib-none { opacity: .6; } +.sp-sib-none .sp-sib-label { color: var(--sp-text-light); } + +/* --------------------------------------------------------------------- + 13. 页脚(深蓝渐变 + 同心圆环纹理 + 三栏 + 底条) + --------------------------------------------------------------------- */ +.sp-footer { + position: relative; + margin-top: 56px; + color: var(--sp-ink-on-dark); + background: linear-gradient(160deg, var(--sp-footer-bg) 0%, var(--sp-primary-dark) 100%); + overflow: hidden; +} + +/* 同心圆环纹理(CSS 绘制,呼应校徽环形语言) */ +.sp-ring { + position: absolute; + border-radius: 50%; + border: 2px solid rgba(255,255,255,.06); + pointer-events: none; +} +.sp-ring--1 { width: 460px; height: 460px; right: -140px; top: -160px; border-width: 30px; } +.sp-ring--2 { width: 300px; height: 300px; right: -60px; top: -80px; border-width: 18px; } +.sp-ring--3 { width: 520px; height: 520px; left: -200px; bottom: -300px; border-width: 34px; } + +.sp-footer-inner { + width: 1200px; max-width: calc(100% - 40px); + margin: 0 auto; + position: relative; z-index: 1; + display: grid; + grid-template-columns: 1.1fr 1.2fr 1.4fr; + gap: 40px; + padding: 44px 0 36px; +} +.sp-footer-inner > * { min-width: 0; } + +.sp-footer-brand { + display: flex; align-items: center; gap: 14px; +} +.sp-footer-badge { + flex: none; + width: 66px; height: 66px; + display: inline-flex; align-items: center; justify-content: center; + background: rgba(255,255,255,.94); + border-radius: var(--sp-radius); + overflow: hidden; + box-shadow: 0 2px 10px rgba(3,61,112,.4); +} +.sp-footer-badge--plain { color: var(--sp-primary); } +.sp-footer-logo { width: 100%; height: 100%; object-fit: contain; } +.sp-footer-site { + font-size: 21px; + font-weight: 700; + letter-spacing: 1.5px; + line-height: 1.45; + text-shadow: 0 2px 6px rgba(3,61,112,.4); +} + +.sp-footer-info { padding-top: 6px; } +.sp-footer-line { + display: flex; align-items: baseline; gap: 10px; + margin-bottom: 10px; + font-size: 13px; + color: var(--sp-ink-on-dark-soft); + line-height: 1.7; +} +.sp-dot { + flex: none; + width: 6px; height: 6px; + background: var(--sp-primary-light); + border-radius: 50%; + transform: translateY(-2px); + box-shadow: 0 0 6px rgba(17,103,187,.9); +} + +.sp-footer-links { + padding-top: 6px; + display: grid; grid-template-columns: 1fr 1fr; gap: 8px 28px; +} +.sp-group-name { + display: inline-block; + font-size: 13px; font-weight: 600; + color: #fff; + padding-bottom: 4px; + margin-bottom: 6px; + border-bottom: 2px solid var(--sp-primary-light); +} +.sp-links-row { + display: flex; flex-wrap: wrap; gap: 4px 14px; + margin-bottom: 10px; +} +.sp-links-row a { + font-size: 13px; + color: var(--sp-ink-on-dark-soft); + transition: color var(--sp-speed) ease, padding-left var(--sp-speed) ease; +} +.sp-links-row a:hover { color: #fff; padding-left: 2px; } + +.sp-footer-bottom { + position: relative; z-index: 1; + border-top: 1px solid rgba(255,255,255,.14); + text-align: center; + padding: 16px 0; + font-size: 13px; + color: var(--sp-ink-on-dark-faint); + letter-spacing: 1px; +} + +/* --------------------------------------------------------------------- + 14. 响应式:1200 / 992 / 768 三档 + --------------------------------------------------------------------- */ +@media (max-width: 1240px) { + .sp-container, .sp-topbar-inner, .sp-brandbar-inner, .sp-nav-inner { max-width: calc(100% - 40px); } +} + +@media (max-width: 992px) { + .sp-site-name { font-size: 22px; max-width: 380px; } + .sp-search { width: 240px; } + + .sp-nav-list { flex-wrap: wrap; justify-content: flex-start; } + .sp-nav-list > li { flex: 0 0 auto; } + .sp-nav-list > li > a { height: 46px; font-size: 15px; padding: 0 12px; } + + .sp-headline { grid-template-columns: 1fr; } + .sp-blocks { grid-template-columns: repeat(2, 1fr); } + + .sp-banner-slides { height: 380px; } + .sp-banner-title { font-size: 19px; bottom: 28px; } + + .sp-footer-inner { grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 768px) { + /* —— 品牌行收纳 —— */ + .sp-brandbar-inner { flex-direction: column; align-items: stretch; gap: 14px; padding: 16px 0; } + .sp-site-name { font-size: 19px; max-width: none; white-space: normal; } + .sp-brand { justify-content: center; } + .sp-search { width: 100%; max-width: none; } + + /* —— 移动端导航:抽屉展开 —— */ + .sp-nav-toggle { + display: inline-flex; flex-direction: column; justify-content: center; gap: 5px; + width: 44px; height: 40px; + padding: 8px; + border: 1px solid var(--sp-border); + border-radius: var(--sp-radius); + background: var(--sp-card-bg); + } + .sp-nav-toggle span { + display: block; height: 2px; + background: var(--sp-primary); + border-radius: 2px; + transition: transform var(--sp-speed) ease, opacity var(--sp-speed) ease; + } + .sp-nav-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); } + .sp-nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; } + .sp-nav-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } + + .sp-nav-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; } + /* 吸顶在移动端直接融入文档流配色 */ + .sp-nav--fixed { position: sticky; animation: none; } + + .sp-nav-list { + display: none; + position: absolute; left: -20px; right: -20px; top: 100%; + flex-direction: column; + background: var(--sp-card-bg); + box-shadow: var(--sp-shadow-md); + border-top: 1px solid var(--sp-border); + padding: 6px 16px 12px; + max-height: 68vh; overflow: auto; + } + .sp-nav-list.sp-open { display: flex; } + .sp-nav-list > li { text-align: left; border-bottom: 1px solid var(--sp-border); } + .sp-nav-list > li:last-child { border-bottom: 0; } + .sp-nav-list > li > a { height: 46px; justify-content: flex-start; } + .sp-nav-list > li > a::after { display: none; } + + /* 移动端二级:手风琴内联展开(.sp-sub-open 由 main.js 控制) */ + .sp-sub { + position: static; + display: none; + transform: none; + opacity: 1; visibility: visible; + background: var(--sp-bg); + border-top: 0; + box-shadow: none; + border-radius: 0; + margin: 0 -8px 8px; + padding: 4px 0; + } + .sp-sub::before { display: none; } + .sp-sub li a { color: var(--sp-text); margin: 0 8px; } + .sp-sub li a:hover { background: rgba(10,90,168,.08); color: var(--sp-primary); } + .sp-sub li a::before { background: var(--sp-primary); opacity: .5; } + .sp-nav-list li.sp-sub-open .sp-sub { display: block; } + .sp-nav-list li.has-sub > a { cursor: pointer; } + + /* —— 内容收纳 —— */ + .sp-banner-slides { height: 240px; } + .sp-banner-title { font-size: 15px; left: 16px; bottom: 20px; max-width: 85%; } + .sp-banner-arrow { display: none; } + + .sp-section { margin-top: 28px; } + .sp-blocks { grid-template-columns: 1fr; } + .sp-block-head h2 { font-size: 19px; } + + .sp-article-row { flex-direction: column; gap: 12px; } + .sp-item-cover { width: 100%; } + .sp-article-line { flex-wrap: wrap; } + .sp-line-side { width: 100%; padding-left: 18px; } + + .sp-page-card { padding: 18px 16px 24px; } + .sp-article-title { font-size: 20px; padding: 0; } + .sp-article-meta { gap: 4px 14px; } + .sp-sibling { grid-template-columns: 1fr; } + .sp-sib-title { white-space: normal; } + + .sp-footer-inner { grid-template-columns: 1fr; gap: 26px; padding: 32px 0 26px; } + .sp-footer-links { grid-template-columns: 1fr; } + .sp-ring--1 { width: 300px; height: 300px; border-width: 20px; } +} + +/* --------------------------------------------------------------------- + 15. 动效偏好:尊重系统减弱动态设置 + --------------------------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + } +} diff --git a/src/main/resources/templates/themes/scholar-blue/assets/favicon.png b/src/main/resources/templates/themes/scholar-blue/assets/favicon.png new file mode 100644 index 0000000..ac16156 Binary files /dev/null and b/src/main/resources/templates/themes/scholar-blue/assets/favicon.png differ diff --git a/src/main/resources/templates/themes/scholar-blue/assets/favicon.svg b/src/main/resources/templates/themes/scholar-blue/assets/favicon.svg new file mode 100644 index 0000000..b64acaf --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/assets/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/templates/themes/scholar-blue/assets/js/main.js b/src/main/resources/templates/themes/scholar-blue/assets/js/main.js new file mode 100644 index 0000000..221c499 --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/assets/js/main.js @@ -0,0 +1,190 @@ +/* scholar-blue 主题脚本:顶栏日期 + 轮播(圆点/箭头/触摸) + 导航吸顶反蓝 + 移动端抽屉与二级手风琴。 + 与 default 同源逻辑,无任何外部依赖。 */ +(function () { + 'use strict'; + + /* ---------- 顶栏日期(本地时间,YYYY年M月D日 星期X) ---------- */ + var dateEl = document.getElementById('siteDate'); + if (dateEl) { + var now = new Date(); + var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()]; + dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + + now.getDate() + '日 星期' + week; + } + + /* ---------- 导航吸顶反蓝(桌面端;≤768 不吸顶) ---------- */ + var nav = document.getElementById('spNav'); + var DESKTOP_MIN = 769; + var navBaseTop = 0; + var holder = null; + + function measureNavTop() { + unlockNav(); + navBaseTop = nav.getBoundingClientRect().top + window.pageYOffset; + } + + function lockNav() { + if (!nav.classList.contains('sp-nav--fixed')) { + holder = document.createElement('div'); + holder.className = 'sp-nav-holder'; + holder.style.height = nav.offsetHeight + 'px'; + nav.parentNode.insertBefore(holder, nav); + nav.classList.add('sp-nav--fixed'); + } else if (holder) { + holder.style.height = nav.offsetHeight + 'px'; + } + } + + function unlockNav() { + nav.classList.remove('sp-nav--fixed'); + if (holder && holder.parentNode) { + holder.parentNode.removeChild(holder); + } + holder = null; + } + + function onScrollNav() { + if (window.innerWidth < DESKTOP_MIN) { + unlockNav(); + return; + } + if (window.pageYOffset > navBaseTop) { + lockNav(); + } else { + unlockNav(); + } + } + + if (nav) { + measureNavTop(); + window.addEventListener('scroll', onScrollNav, { passive: true }); + var resizeTimer = null; + window.addEventListener('resize', function () { + if (resizeTimer) { window.clearTimeout(resizeTimer); } + resizeTimer = window.setTimeout(function () { + measureNavTop(); + onScrollNav(); + }, 150); + }); + } + + /* ---------- 移动端抽屉 + 二级手风琴 ---------- */ + var navToggle = document.querySelector('.sp-nav-toggle'); + var navList = document.querySelector('.sp-nav-list'); + + if (navToggle && navList) { + navToggle.addEventListener('click', function () { + var open = navList.classList.toggle('sp-open'); + navToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + } + + /* 窄屏下:点击带二级的项先展开面板,再点才跳转;宽屏交还给 CSS hover */ + if (navList) { + navList.addEventListener('click', function (e) { + if (window.innerWidth >= DESKTOP_MIN) { return; } + var link = e.target.closest ? e.target.closest('a') : null; + if (!link) { return; } + var li = link.parentElement; + if (!li || !li.classList.contains('has-sub')) { return; } + var sub = li.querySelector('.sp-sub'); + if (!sub) { return; } + if (!li.classList.contains('sp-sub-open')) { + e.preventDefault(); + li.classList.add('sp-sub-open'); + } + }); + } + + /* ---------- 首页轮播 ---------- */ + var root = document.querySelector('.sp-banner'); + var slides = document.querySelectorAll('.sp-banner-slide'); + var dots = document.querySelectorAll('.sp-banner-dots button'); + var prevBtn = document.querySelector('.sp-banner-arrow.prev'); + var nextBtn = document.querySelector('.sp-banner-arrow.next'); + if (!slides.length || !root) { + return; + } + var current = 0; + var timer = null; + var INTERVAL = 5000; + + function show(index) { + slides[current].classList.remove('active'); + if (dots[current]) { + dots[current].classList.remove('active'); + } + current = (index + slides.length) % slides.length; + slides[current].classList.add('active'); + if (dots[current]) { + dots[current].classList.add('active'); + } + } + + function start() { + stop(); + if (slides.length > 1) { + timer = window.setInterval(function () { + show(current + 1); + }, INTERVAL); + } + } + + function stop() { + if (timer) { + window.clearInterval(timer); + timer = null; + } + } + + dots.forEach(function (dot, index) { + dot.addEventListener('click', function () { + show(index); + start(); + }); + }); + if (prevBtn) { + prevBtn.addEventListener('click', function () { + show(current - 1); + start(); + }); + } + if (nextBtn) { + nextBtn.addEventListener('click', function () { + show(current + 1); + start(); + }); + } + root.addEventListener('mouseenter', stop); + root.addEventListener('mouseleave', start); + root.addEventListener('focusin', stop); + root.addEventListener('focusout', start); + root.setAttribute('tabindex', '-1'); + root.addEventListener('keydown', function (e) { + if (e.key === 'ArrowLeft') { + show(current - 1); + start(); + } else if (e.key === 'ArrowRight') { + show(current + 1); + start(); + } + }); + + /* 触摸滑动切页 */ + var touchX = null; + root.addEventListener('touchstart', function (e) { + touchX = e.changedTouches[0].clientX; + stop(); + }, { passive: true }); + root.addEventListener('touchend', function (e) { + if (touchX === null) { return; } + var dx = e.changedTouches[0].clientX - touchX; + if (Math.abs(dx) > 40) { + show(dx < 0 ? current + 1 : current - 1); + } + touchX = null; + start(); + }, { passive: true }); + + start(); +})(); diff --git a/src/main/resources/templates/themes/scholar-blue/assets/screenshot.png b/src/main/resources/templates/themes/scholar-blue/assets/screenshot.png new file mode 100644 index 0000000..c59be31 Binary files /dev/null and b/src/main/resources/templates/themes/scholar-blue/assets/screenshot.png differ diff --git a/src/main/resources/templates/themes/scholar-blue/detail.html b/src/main/resources/templates/themes/scholar-blue/detail.html new file mode 100644 index 0000000..190af54 --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/detail.html @@ -0,0 +1,60 @@ + + + + +
+ +
+
+ + +
+

文章标题

+ + +
正文
+
+ + +
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/scholar-blue/fragments/footer.html b/src/main/resources/templates/themes/scholar-blue/fragments/footer.html new file mode 100644 index 0000000..dd56587 --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/fragments/footer.html @@ -0,0 +1,56 @@ + + + +
+ + + + + + + +
+ diff --git a/src/main/resources/templates/themes/scholar-blue/fragments/head.html b/src/main/resources/templates/themes/scholar-blue/fragments/head.html new file mode 100644 index 0000000..0197cd1 --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/fragments/head.html @@ -0,0 +1,19 @@ + + + + + + + 页面标题 + + + + + + + + + + diff --git a/src/main/resources/templates/themes/scholar-blue/fragments/header.html b/src/main/resources/templates/themes/scholar-blue/fragments/header.html new file mode 100644 index 0000000..4c0fdfd --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/fragments/header.html @@ -0,0 +1,85 @@ + + + +
+ + + +
+
+ 加载日期… +
+
+ + 专业院系 + + + +
+
+
+
+ + +
+
+
+ + + + + 站点名称 +
+ +
+
+ + + +
+ diff --git a/src/main/resources/templates/themes/scholar-blue/home.html b/src/main/resources/templates/themes/scholar-blue/home.html new file mode 100644 index 0000000..e5d9eaa --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/home.html @@ -0,0 +1,124 @@ + + + + +
+ +
+ +
+ + + +
+ +
+
+ + +
+
+
+
+

头条推荐

+ +
+ +
+ +
+
+ + +
+
+
+
+

栏目名

+ 更多 › +
+ +
+

栏目暂无内容

+

内容发布后将在此展示

+
+
+
+
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/scholar-blue/list.html b/src/main/resources/templates/themes/scholar-blue/list.html new file mode 100644 index 0000000..99ad26b --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/list.html @@ -0,0 +1,86 @@ + + + + +
+ +
+
+ + + +

栏目名

+

搜索词

+ + +
+

未找到相关内容

+

栏目暂无内容

+

换个关键词试试

+

内容发布后将在此展示

+
+ + + +
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/scholar-blue/single.html b/src/main/resources/templates/themes/scholar-blue/single.html new file mode 100644 index 0000000..21b156e --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/single.html @@ -0,0 +1,31 @@ + + + + +
+ +
+
+ + +
+

栏目名

+ +
单页内容
+
+

页面暂无内容

+

内容编辑后将在此展示

+
+
+
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/scholar-blue/theme.json b/src/main/resources/templates/themes/scholar-blue/theme.json new file mode 100644 index 0000000..02a21ac --- /dev/null +++ b/src/main/resources/templates/themes/scholar-blue/theme.json @@ -0,0 +1,22 @@ +{ + "specVersion": 1, + "name": "scholar-blue", + "title": "学院蓝(scholar-blue)", + "version": "1.0.0", + "author": "yunhai", + "description": "学院蓝·经典学院派门户:深蓝品牌行+白底主导航(吸顶反蓝)+主蓝半透明下拉面板+居中双线栏目标题+日期方块+圆环纹理页脚(设计语言参考建平中学,仅学设计不复制实现)。", + "screenshot": "assets/screenshot.png", + "bannerSize": "1920x560", + "params": [ + { "key": "primary", "cssVar": "--sp-primary", "label": "主色", "type": "color", "default": "#0a5aa8" }, + { "key": "primaryDark", "cssVar": "--sp-primary-dark", "label": "主色加深", "type": "color", "default": "#033d70" }, + { "key": "primaryLight", "cssVar": "--sp-primary-light","label": "主色提亮", "type": "color", "default": "#1167bb" }, + { "key": "navBg", "cssVar": "--sp-nav-bg", "label": "导航条底色", "type": "color", "default": "#ffffff" }, + { "key": "footerBg", "cssVar": "--sp-footer-bg", "label": "页脚底色", "type": "color", "default": "#0b4781" }, + { "key": "text", "cssVar": "--sp-text", "label": "正文文字色", "type": "color", "default": "#333333" }, + { "key": "textLight", "cssVar": "--sp-text-light", "label": "次要文字色", "type": "color", "default": "#777777" }, + { "key": "bg", "cssVar": "--sp-bg", "label": "页面底色", "type": "color", "default": "#eff2f6" }, + { "key": "cardBg", "cssVar": "--sp-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" }, + { "key": "border", "cssVar": "--sp-border", "label": "边框色", "type": "color", "default": "#e3e7ee" } + ] +} diff --git a/src/main/resources/templates/themes/warm-kinder/README.md b/src/main/resources/templates/themes/warm-kinder/README.md new file mode 100644 index 0000000..fd9c0d2 --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/README.md @@ -0,0 +1,23 @@ +# warm-kinder 皮肤包 + +由 default 派生的演示皮肤(方案 §7)。**升级随版本发布,不在站点间复制目录**。 + +## 数据契约(模板可取用的 model 属性全集,§4.3) + +| 页面 | 属性 | +| --- | --- | +| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/asset/search/siteHome)、`themeBase`(=`themes/warm-kinder`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) | +| home | `banners`(时间窗内 home 位)、`topArticles`(置顶+最新 6)、`categoryBlocks`(LIST 型根栏目块,各 top 8) | +| list / search | `listData`(分页)、`category`(list)、`q` + `searchMode`(search) | +| detail | `article`、`prevArticle`、`nextArticle`、`currentCategoryId` | +| single | `category`(含 `content`)、`currentCategoryId` | + +原则:皮肤不得假设"首页一定有轮播",对空集合优雅降级。 + +## 轮播图尺寸约定(bannerSize) + +1920x560(管理端上传端按此引导裁剪,D2 决策点)。 + +## 可调参数 + +见 `theme.json` 的 `params`(color 进 CSS 变量;select 供模板 `th:if` 分支布局)。 diff --git a/src/main/resources/templates/themes/warm-kinder/assets/css/main.css b/src/main/resources/templates/themes/warm-kinder/assets/css/main.css new file mode 100644 index 0000000..30e0c91 --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/assets/css/main.css @@ -0,0 +1,332 @@ +/* ============================================================ + * warm-kinder 皮肤包样式(default 派生)(方案 §8.2) + * 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪,D2 决策点确认)。 + * ============================================================ */ +:root { + --primary: #e8722a; + --primary-dark: #c25a1a; + --text: #333; + --text-light: #777; + --bg: #fff8f0; + --card-bg: #fffdf9; + --border: #f3e3d3; + --max-width: 1200px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} +a { color: inherit; text-decoration: none; } +a:hover { color: var(--primary); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { color: var(--text-light); text-align: center; padding: 32px 0; } + +/* ===== 头部 ===== */ +.site-header { background: var(--card-bg); border-bottom: 1px solid var(--border); } +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.site-brand { display: flex; align-items: center; gap: 12px; } +.site-logo { height: 44px; } +.site-name { font-size: 26px; font-weight: 700; color: var(--primary); } +.site-search { display: flex; } +.site-search input { + border: 1px solid var(--border); + border-right: none; + padding: 8px 12px; + width: 220px; + outline: none; +} +.site-search button { + border: none; + background: var(--primary); + color: #fff; + padding: 8px 18px; + cursor: pointer; +} + +/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */ +/* 悬停展开白底纵向列表,右对齐 + 超高滚动(院系数十个子站场景) */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + padding: 8px 14px; + cursor: pointer; + color: var(--primary); + font-weight: 600; + white-space: nowrap; +} +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 110; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: 9px 16px; + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: #f2f6fc; color: var(--primary); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } +.site-nav { background: var(--primary); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + flex-wrap: wrap; +} +.site-nav li a { + display: block; + padding: 12px 22px; + color: #fff; +} +.site-nav li:hover a, .site-nav li.active a { background: var(--primary-dark); } + +/* ===== 二级下拉导航 ===== */ +/* 面板为白底纵向列表:需整体覆盖 .site-nav ul 的横向 flex 与内边距 */ +.site-nav > ul > li { position: relative; } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .85; } +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 168px; + max-width: none; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 100; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: 9px 16px; + color: var(--text); + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a:hover { background: #f2f6fc; color: var(--primary); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播 ===== */ +.banner { position: relative; max-width: var(--max-width); margin: 16px auto; overflow: hidden; } +.banner-slides { position: relative; height: 420px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 12px 20px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, .6)); +} +.banner-dots { + position: absolute; + bottom: 12px; right: 20px; + display: flex; gap: 8px; +} +.banner-dots button { + width: 10px; height: 10px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .6); + cursor: pointer; +} +.banner-dots button.active { background: #fff; } + +/* ===== 通用区块 ===== */ +.top-strip, .category-block { + max-width: var(--max-width); + margin: 16px auto; + padding: 20px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.section-title, .block-head h2 { + margin: 0 0 12px; + font-size: 20px; + border-left: 4px solid var(--primary); + padding-left: 10px; +} +.block-head { display: flex; align-items: baseline; justify-content: space-between; } +.more { font-size: 13px; color: var(--text-light); } +.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; } +.top-strip li, .block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px dashed var(--border); +} +.top-strip li:last-child, .block-list li:last-child { border-bottom: none; } +.top-strip li a, .block-list li a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-badge { + flex: none; + color: #fff; + background: var(--primary); + font-size: 12px; + padding: 1px 6px; + border-radius: 2px; +} + +/* ===== 列表页 ===== */ +.list-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 24px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 16px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; } +.page-title { + font-size: 22px; + border-left: 4px solid var(--primary); + padding-left: 10px; + margin: 0 0 18px; +} +.article-list { list-style: none; margin: 0; padding: 0; } +.article-list li { padding: 14px 0; border-bottom: 1px dashed var(--border); } +.item-title { font-size: 17px; } +.item-summary { + color: var(--text-light); + font-size: 14px; + margin: 6px 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.item-meta { display: flex; gap: 10px; align-items: center; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: 6px; justify-content: center; padding: 24px 0 4px; } +.pagination a { + min-width: 34px; + text-align: center; + padding: 6px 10px; + border: 1px solid var(--border); + background: var(--card-bg); +} +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情/单页 ===== */ +.detail-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.article-title { margin: 8px 0 12px; font-size: 26px; text-align: center; } +.article-meta { + display: flex; + gap: 18px; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.article-content { line-height: 1.9; font-size: 16px; padding-top: 20px; word-break: break-word; } +.article-content img { height: auto; } +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; } +.article-sibling { display: flex; flex-direction: column; gap: 8px; margin-top: 28px; font-size: 14px; } + +/* ===== 页脚 ===== */ +.site-footer { margin-top: 32px; background: var(--primary-dark); color: rgba(255, 255, 255, .85); } +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; } +.group-name { font-weight: 600; margin-right: 4px; } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 12px 16px 20px; + text-align: center; + font-size: 13px; +} +.footer-info p { margin: 4px 0; } + +/* ===== 移动端适配 ===== */ +@media (max-width: 768px) { + .header-inner { flex-direction: column; align-items: stretch; } + .site-search input { flex: 1; width: auto; } + .site-colleges { align-self: flex-start; } + .site-colleges .colleges-menu { left: 0; right: auto; min-width: 100%; } + .banner-slides { height: 200px; } + .detail-main, .list-main { padding: 18px 14px; } + .article-title { font-size: 22px; } + /* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: transparent; + min-width: 0; + padding: 0 0 6px; + } + .site-nav .sub-nav li a { color: rgba(255, 255, 255, .9); padding: 8px 16px 8px 34px; } + .site-nav .sub-nav li a:hover { background: var(--primary-dark); color: #fff; } +} + +/* ===== warm-kinder 主题差异层(方案 §7:差异仅限 CSS + fragments)===== */ +/* 圆角卡片 + 暖色系 */ +.site-header { border-radius: 0 0 24px 24px; } +.site-footer { border-radius: 24px 24px 0 0; } +.banner, .banner-title { border-radius: 20px; overflow: hidden; } +.site-search input, .site-search button { border-radius: 999px; } +.footer-info p { color: var(--primary-dark); } diff --git a/src/main/resources/templates/themes/warm-kinder/assets/favicon.png b/src/main/resources/templates/themes/warm-kinder/assets/favicon.png new file mode 100644 index 0000000..61da3e3 Binary files /dev/null and b/src/main/resources/templates/themes/warm-kinder/assets/favicon.png differ diff --git a/src/main/resources/templates/themes/warm-kinder/assets/favicon.svg b/src/main/resources/templates/themes/warm-kinder/assets/favicon.svg new file mode 100644 index 0000000..64db783 --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/themes/warm-kinder/assets/js/main.js b/src/main/resources/templates/themes/warm-kinder/assets/js/main.js new file mode 100644 index 0000000..2314bae --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/assets/js/main.js @@ -0,0 +1,40 @@ +/* default 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */ +(function () { + var slides = document.querySelectorAll('.banner-slide'); + var dots = document.querySelectorAll('.banner-dots button'); + if (!slides.length) { + return; + } + var current = 0; + var timer = null; + + function show(index) { + slides[current].classList.remove('active'); + if (dots[current]) { + dots[current].classList.remove('active'); + } + current = (index + slides.length) % slides.length; + slides[current].classList.add('active'); + if (dots[current]) { + dots[current].classList.add('active'); + } + } + + function start() { + if (slides.length > 1) { + timer = window.setInterval(function () { + show(current + 1); + }, 5000); + } + } + + dots.forEach(function (dot, index) { + dot.addEventListener('click', function () { + window.clearInterval(timer); + show(index); + start(); + }); + }); + + start(); +})(); diff --git a/src/main/resources/templates/themes/warm-kinder/assets/screenshot.png b/src/main/resources/templates/themes/warm-kinder/assets/screenshot.png new file mode 100644 index 0000000..8d5a05b Binary files /dev/null and b/src/main/resources/templates/themes/warm-kinder/assets/screenshot.png differ diff --git a/src/main/resources/templates/themes/warm-kinder/detail.html b/src/main/resources/templates/themes/warm-kinder/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/main/resources/templates/themes/warm-kinder/fragments/footer.html b/src/main/resources/templates/themes/warm-kinder/fragments/footer.html new file mode 100644 index 0000000..8094359 --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/fragments/footer.html @@ -0,0 +1,19 @@ + + + +
+ + +
+ diff --git a/src/main/resources/templates/themes/warm-kinder/fragments/head.html b/src/main/resources/templates/themes/warm-kinder/fragments/head.html new file mode 100644 index 0000000..9fcb6b4 --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/fragments/head.html @@ -0,0 +1,22 @@ + + + + + + + 页面标题 + + + + + + + + + + + + + diff --git a/src/main/resources/templates/themes/warm-kinder/fragments/header.html b/src/main/resources/templates/themes/warm-kinder/fragments/header.html new file mode 100644 index 0000000..3704f5d --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/fragments/header.html @@ -0,0 +1,53 @@ + + + + + diff --git a/src/main/resources/templates/themes/warm-kinder/home.html b/src/main/resources/templates/themes/warm-kinder/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/warm-kinder/list.html b/src/main/resources/templates/themes/warm-kinder/list.html new file mode 100644 index 0000000..fec0f2c --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/list.html @@ -0,0 +1,50 @@ + + + + +
+ +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/main/resources/templates/themes/warm-kinder/single.html b/src/main/resources/templates/themes/warm-kinder/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/main/resources/templates/themes/warm-kinder/theme.json b/src/main/resources/templates/themes/warm-kinder/theme.json new file mode 100644 index 0000000..c01b60a --- /dev/null +++ b/src/main/resources/templates/themes/warm-kinder/theme.json @@ -0,0 +1,17 @@ +{ + "specVersion": 1, + "name": "warm-kinder", + "title": "幼儿园暖色", + "version": "1.0.0", + "author": "yunhai", + "description": "圆角卡片 + 暖色系 + 插画头图位,适合幼儿园与托育机构(方案 §7)。", + "screenshot": "assets/screenshot.png", + "bannerSize": "1920x560", + "params": [ + { "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#e8722a" }, + { "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深", "type": "color", "default": "#c25a1a" }, + { "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#fff8f0" }, + { "key": "cardBg", "cssVar": "--card-bg", "label": "卡片底色", "type": "color", "default": "#fffdf9" }, + { "key": "border", "cssVar": "--border", "label": "边框色", "type": "color", "default": "#f3e3d3" } + ] +} diff --git a/src/test/java/com/yunhai/cms/config/CmsAuthPropertiesTest.java b/src/test/java/com/yunhai/cms/config/CmsAuthPropertiesTest.java new file mode 100644 index 0000000..47fee7f --- /dev/null +++ b/src/test/java/com/yunhai/cms/config/CmsAuthPropertiesTest.java @@ -0,0 +1,43 @@ +package com.yunhai.cms.config; + +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 超管判定单测(对齐 UserBase GroupAuthzService#isSuperAdmin): + * type=1 命中;基石账号 id=100000 命中;type=2 学校管理员与无 type 用户不命中。 + */ +class CmsAuthPropertiesTest { + + private final CmsAuthProperties props = new CmsAuthProperties(); + + @Test + void type1IsSuperAdmin() { + assertTrue(props.isSuperAdmin(TestUsers.superAdmin())); + } + + @Test + void platformRootUserIdIsSuperAdmin() { + BoaUser root = new BoaUser(); + root.setId(100000); + assertTrue(props.isSuperAdmin(root)); + } + + @Test + void schoolAdminType2IsNotSuperAdmin() { + BoaUser schoolAdmin = new BoaUser(); + schoolAdmin.setId(50); + schoolAdmin.setType(2); + assertFalse(props.isSuperAdmin(schoolAdmin)); + } + + @Test + void nullOrNoTypeIsNotSuperAdmin() { + assertFalse(props.isSuperAdmin(null)); + assertFalse(props.isSuperAdmin(TestUsers.plainUser())); + } +} diff --git a/src/test/java/com/yunhai/cms/enums/EnumsContractTest.java b/src/test/java/com/yunhai/cms/enums/EnumsContractTest.java new file mode 100644 index 0000000..05e4f1a --- /dev/null +++ b/src/test/java/com/yunhai/cms/enums/EnumsContractTest.java @@ -0,0 +1,54 @@ +package com.yunhai.cms.enums; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 枚举契约单测:code 与 DDL 注释(方案 §5.1)一致;byCode 对 null/未知值 + * 一律回退默认枚举(脏数据不抛异常)。 + */ +@DisplayName("枚举 code 契约") +class EnumsContractTest { + + @Test + @DisplayName("ArticleStatus:0=草稿 1=已发布 2=已下架") + void articleStatusCodes() { + assertThat(ArticleStatusEnum.DRAFT.getCode()).isZero(); + assertThat(ArticleStatusEnum.PUBLISHED.getCode()).isEqualTo(1); + assertThat(ArticleStatusEnum.OFFLINE.getCode()).isEqualTo(2); + } + + @Test + @DisplayName("CategoryType:1=文章列表 2=单页 3=外链") + void categoryTypeCodes() { + assertThat(CategoryTypeEnum.LIST.getCode()).isEqualTo(1); + assertThat(CategoryTypeEnum.SINGLE.getCode()).isEqualTo(2); + assertThat(CategoryTypeEnum.LINK.getCode()).isEqualTo(3); + } + + @Test + @DisplayName("LinkType(轮播复用):1=文章 2=栏目 3=外链") + void linkTypeCodes() { + assertThat(LinkTypeEnum.ARTICLE.getCode()).isEqualTo(1); + assertThat(LinkTypeEnum.CATEGORY.getCode()).isEqualTo(2); + assertThat(LinkTypeEnum.EXTERNAL.getCode()).isEqualTo(3); + } + + @Test + @DisplayName("byCode:null/未知值回退默认枚举,合法值双向互查") + void byCode_fallbackAndRoundTrip() { + assertThat(ArticleStatusEnum.byCode(null)).isEqualTo(ArticleStatusEnum.DRAFT); + assertThat(ArticleStatusEnum.byCode(99)).isEqualTo(ArticleStatusEnum.DRAFT); + assertThat(ArticleStatusEnum.byCode(2)).isEqualTo(ArticleStatusEnum.OFFLINE); + + assertThat(CategoryTypeEnum.byCode(null)).isEqualTo(CategoryTypeEnum.LIST); + assertThat(CategoryTypeEnum.byCode(-1)).isEqualTo(CategoryTypeEnum.LIST); + assertThat(CategoryTypeEnum.byCode(3)).isEqualTo(CategoryTypeEnum.LINK); + + assertThat(LinkTypeEnum.byCode(null)).isEqualTo(LinkTypeEnum.ARTICLE); + assertThat(LinkTypeEnum.byCode(0)).isEqualTo(LinkTypeEnum.ARTICLE); + assertThat(LinkTypeEnum.byCode(2)).isEqualTo(LinkTypeEnum.CATEGORY); + } +} diff --git a/src/test/java/com/yunhai/cms/portal/PortalCollegeNavIT.java b/src/test/java/com/yunhai/cms/portal/PortalCollegeNavIT.java new file mode 100644 index 0000000..ad1c188 --- /dev/null +++ b/src/test/java/com/yunhai/cms/portal/PortalCollegeNavIT.java @@ -0,0 +1,102 @@ +package com.yunhai.cms.portal; + +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 门户"专业院系"下拉与站点层级集成测试(《院系子站方案》): + * 主站/院系子站 header 聚合同校子站、停用与他校子站过滤、无子站不渲染、/portal 主站优先。 + */ +@DisplayName("Portal 院系子站 - 专业院系下拉与站点层级") +class PortalCollegeNavIT extends CmsIntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + + private Integer mainId; + private Integer jdxId; + + @BeforeEach + void seedCollegeSites() { + purgeTables(); + // 先建院系子站再建主站:验证 /portal 不再按 id 顺序取第一个站点 + jdxId = persistSite("pjdx", 1, "机电学院", 0).getId(); // parentSiteId 稍后修正为主站 id + mainId = persistSite("pmain", 1, "云海大学门户").getId(); + CmsSite jdx = siteRepository.findById(jdxId).get(); + jdx.setParentSiteId(mainId); + siteRepository.saveAndFlush(jdx); + persistSite("pxxgc", 1, "信息工程学院", mainId); + // 停用院系(不出现在下拉) + CmsSite stopped = persistSite("pstop", 1, "停用学院", mainId); + stopped.setStatus(0); + siteRepository.saveAndFlush(stopped); + // 他校顶层站 + 他校子站(跨校防御过滤)+ 他校无子站顶层站(下拉不渲染) + persistSite("pother", 2, "另一大学门户"); + persistSite("pojdx", 2, "他校机电学院", siteRepository.findByCode("pother").get().getId()); + persistSite("pnone", 2, "无子站学院门户"); + } + + @AfterEach + void clean() { + purgeTables(); + } + + private String body(MvcResult result) throws java.io.UnsupportedEncodingException { + return result.getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + @Test + @DisplayName("主站首页:专业院系下拉聚合本校启用子站,停用/他校子站不出现") + void home_mainSiteRendersColleges() throws Exception { + String html = body(mockMvc.perform(get("/portal/pmain/")).andExpect(status().isOk()).andReturn()); + assertThat(html).contains("专业院系").contains("机电学院").contains("信息工程学院"); + assertThat(html).doesNotContain("停用学院").doesNotContain("他校机电学院"); + } + + @Test + @DisplayName("院系子站首页:下拉为兄弟站集合(含自身),当前站高亮") + void home_collegeSiteRendersSiblingColleges() throws Exception { + String html = body(mockMvc.perform(get("/portal/pjdx/")).andExpect(status().isOk()).andReturn()); + assertThat(html).contains("专业院系").contains("机电学院").contains("信息工程学院"); + // 当前站点 li 高亮 active + assertThat(html).containsPattern("class=\"active\"[^>]*>\\s*]*>机电学院"); + } + + @Test + @DisplayName("无子站站点:下拉不渲染") + void home_siteWithoutCollegesHidesDropdown() throws Exception { + String html = body(mockMvc.perform(get("/portal/pnone/")).andExpect(status().isOk()).andReturn()); + assertThat(html).doesNotContain("专业院系"); + } + + @Test + @DisplayName("/portal 根路径:优先跳顶层校站点,院系子站不作默认入口") + void index_prefersTopLevelSite() throws Exception { + mockMvc.perform(get("/portal")).andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", org.hamcrest.Matchers.containsString("/portal/pmain/"))); + } + + @Test + @DisplayName("B-5:多顶层站并存时入口按 id 升序确定,后建顶层站不改变 302 目标(T-7)") + void index_multipleTopLevelSitesDeterministic() throws Exception { + // 种子中 pmain 为先建的顶层站;再后建两个顶层站,入口仍稳定指向 id 最小的 pmain + persistSite("plater1", 1, "后建顶层站一"); + persistSite("plater2", 1, "后建顶层站二"); + mockMvc.perform(get("/portal")).andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", org.hamcrest.Matchers.containsString("/portal/pmain/"))); + } +} diff --git a/src/test/java/com/yunhai/cms/portal/PortalErrorPageIT.java b/src/test/java/com/yunhai/cms/portal/PortalErrorPageIT.java new file mode 100644 index 0000000..21d2794 --- /dev/null +++ b/src/test/java/com/yunhai/cms/portal/PortalErrorPageIT.java @@ -0,0 +1,92 @@ +package com.yunhai.cms.portal; + +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 门户错误页与参数异常落地(体检 H-1/H-2、bug D-1 回归)。 + * + *

必须起真实端口:MockMvc 不执行 ERROR dispatch,sendError 后的 /error 错误页 + * (templates/error/4xx.html)只有在真实 Tomcat 上才会渲染。 + */ +@DisplayName("门户错误页 - 404/400 皮肤化 + 三级栏目可达(真实端口)") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class PortalErrorPageIT extends CmsIntegrationTestBase { + + @Autowired + private TestRestTemplate rest; + + private Integer lv1Id; + private Integer lv3Id; + private Integer articleId; + + @BeforeEach + void seed() { + purgeTables(); + Integer siteId = persistSite("errdemo", 1, "错误页演示站").getId(); + lv1Id = persistCategory(siteId, 0, "一级栏目", CategoryTypeEnum.LIST).getId(); + Integer lv2Id = persistCategory(siteId, lv1Id, "二级栏目", CategoryTypeEnum.LIST).getId(); + lv3Id = persistCategory(siteId, lv2Id, "三级栏目", CategoryTypeEnum.LIST).getId(); + articleId = persistArticle(siteId, lv3Id, "三级栏目下的文章", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60)).getId(); + } + + /** 以浏览器身份请求(Accept: text/html)——错误页协商只对 HTML 请求生效 */ + private ResponseEntity getAsBrowser(String url, Object... uriVars) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.ACCEPT, "text/html,application/xhtml+xml,*/*;q=0.8"); + return rest.exchange(url, HttpMethod.GET, new HttpEntity(headers), String.class, uriVars); + } + + @Test + @DisplayName("404:不存在的站点渲染 HTML 错误页(非 Spring JSON)") + void notFound_rendersHtmlErrorPage() { + ResponseEntity resp = getAsBrowser("/portal/no-such-site/"); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(resp.getBody()).contains("页面不存在"); + assertThat(resp.getBody()).doesNotContain("\"status\":404"); + assertThat(MediaType.parseMediaType(resp.getHeaders().getContentType().toString()) + .includes(MediaType.TEXT_HTML)).isTrue(); + } + + @Test + @DisplayName("404:不存在的文章渲染 HTML 错误页") + void notFoundArticle_rendersHtmlErrorPage() { + ResponseEntity resp = getAsBrowser("/portal/errdemo/a/99999"); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(resp.getBody()).contains("页面不存在"); + } + + @Test + @DisplayName("400:page=abc 渲染 HTML 错误页(非 code=6 JSON)") + void badPageParam_rendersHtmlErrorPage() { + ResponseEntity resp = getAsBrowser("/portal/errdemo/c/{id}?page=abc", lv1Id); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(resp.getBody()).contains("请求参数有误"); + assertThat(resp.getBody()).doesNotContain("\"code\":6"); + } + + @Test + @DisplayName("三级栏目页可达(findCategory 递归修复,bug D-1)") + void thirdLevelCategory_pageReachable() { + assertThat(rest.getForEntity("/portal/errdemo/c/{id}", String.class, lv3Id) + .getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(rest.getForEntity("/portal/errdemo/a/{id}", String.class, articleId) + .getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java b/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java new file mode 100644 index 0000000..f21f1dc --- /dev/null +++ b/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java @@ -0,0 +1,363 @@ +package com.yunhai.cms.portal; + +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 门户 SSR 全路由集成测试(方案 §8:匿名可访问 + 真实 URL + SEO)。 + * MockMvc 走完整过滤器链:同时验证 CAS 只拦 /api/*、门户匿名(方案 §7.1,§13 风险表)。 + */ +@DisplayName("Portal SSR - 门户路由与匿名访问") +class PortalSsrIT extends CmsIntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + + private Integer siteId; + private Integer listCatId; + private Integer singleCatId; + private Integer linkCatId; + private Integer articleId; + + @BeforeEach + void seedFullPortal() { + purgeTables(); + siteId = persistSite("demo1", 1, "云海第一中学门户").getId(); + listCatId = persistCategory(siteId, 0, "新闻中心", CategoryTypeEnum.LIST).getId(); + singleCatId = persistCategory(siteId, 0, "学校简介", CategoryTypeEnum.SINGLE).getId(); + linkCatId = persistCategory(siteId, 0, "教育部链接", CategoryTypeEnum.LINK).getId(); + categoryRepository.findById(linkCatId).ifPresent(c -> { + c.setLinkUrl("https://www.moe.gov.cn"); + categoryRepository.saveAndFlush(c); + }); + categoryRepository.findById(singleCatId).ifPresent(c -> { + c.setContent("

云海第一中学始建于 1958 年。

"); + categoryRepository.saveAndFlush(c); + }); + + articleId = persistArticle(siteId, listCatId, "开学典礼隆重举行", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(120)).getId(); + + persistBanner(siteId, "home", "在窗轮播图", nowMinusSeconds(60), nowPlusSeconds(3600)); + persistBanner(siteId, "home", "未上线轮播图", nowPlusSeconds(3600), nowPlusSeconds(7200)); + persistBanner(siteId, "home", "已下线轮播图", nowMinusSeconds(7200), nowMinusSeconds(60)); + + persistLink(siteId, "上级部门", "教育部"); + persistLink(siteId, "兄弟院校", "市第一中学"); + } + + @AfterEach + void clean() { + purgeTables(); + } + + private String body(MvcResult result) throws java.io.UnsupportedEncodingException { + return result.getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + // ===== 首页 ===== + + @Test + @DisplayName("首页:200 匿名渲染,含站点名/轮播(时间窗内)/栏目/置顶文章/友链分组/canonical") + void home_rendersSnapshotData() throws Exception { + MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()) + .andReturn(); + String html = body(result); + assertThat(html) + .contains("云海第一中学门户") + .contains("在窗轮播图") + .contains("新闻中心") + .contains("开学典礼隆重举行") + .contains("教育部") + .contains("rel=\"canonical\""); + // 时间窗外轮播被过滤(§5.1 表5:上线/下线时间窗) + assertThat(html).doesNotContain("未上线轮播图").doesNotContain("已下线轮播图"); + } + + @Test + @DisplayName("首页:不存在/停用站点 404") + void home_unknownSite404() throws Exception { + mockMvc.perform(get("/portal/no-such-site/")).andExpect(status().isNotFound()); + } + + // ===== 栏目页 ===== + + @Test + @DisplayName("列表栏目:分页渲染文章标题,SEO 标题带站点名") + void category_listPage() throws Exception { + MvcResult result = mockMvc.perform(get("/portal/demo1/c/{id}", listCatId)) + .andExpect(status().isOk()).andReturn(); + String html = body(result); + assertThat(html).contains("新闻中心").contains("开学典礼隆重举行"); + assertThat(html).contains(""); + } + + @Test + @DisplayName("列表栏目:page 超出总页数返回空页不报错(边界)") + void category_pageBeyondRange() throws Exception { + mockMvc.perform(get("/portal/demo1/c/{id}", listCatId).param("page", "999")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("单页栏目:直接渲染 content(§8.1:单页栏目渲染 content)") + void category_singlePage() throws Exception { + MvcResult result = mockMvc.perform(get("/portal/demo1/c/{id}", singleCatId)) + .andExpect(status().isOk()).andReturn(); + assertThat(body(result)).contains("云海第一中学始建于 1958 年"); + } + + @Test + @DisplayName("外链栏目:302 跳转 link_url(§8.1)") + void category_linkRedirects() throws Exception { + mockMvc.perform(get("/portal/demo1/c/{id}", linkCatId)) + .andExpect(status().is3xxRedirection()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .contains("moe.gov.cn")); + } + + @Test + @DisplayName("隐藏栏目:门户 404(管理端可见、门户不可见)") + void category_hidden404() throws Exception { + Integer hiddenId = persistCategory(siteId, 0, "隐藏栏目", CategoryTypeEnum.LIST).getId(); + categoryRepository.findById(hiddenId).ifPresent(c -> { + c.setStatus(0); + categoryRepository.saveAndFlush(c); + }); + // 清缓存使快照重新加载(快照缓存对门户生效,此为正常行为) + clearCaches(); + mockMvc.perform(get("/portal/demo1/c/{id}", hiddenId)).andExpect(status().isNotFound()); + } + + /** + * 验收问题 A-03(清单 A-1):门户栏目查找仅覆盖两层(根+子)时三级栏目恒 404。 + * 已由 D-1 修复(findCategory 全树递归查找),2026-09-07 解禁本守护测试。 + */ + @Test + void category_thirdLevelShouldBeAccessible() throws Exception { + Integer childId = persistCategory(siteId, listCatId, "二级栏目", CategoryTypeEnum.LIST).getId(); + Integer grandchildId = persistCategory(siteId, childId, "三级栏目", CategoryTypeEnum.LIST).getId(); + clearCaches(); + mockMvc.perform(get("/portal/demo1/c/{id}", grandchildId)).andExpect(status().isOk()); + } + + // ===== 文章详情 ===== + + @Test + @DisplayName("文章详情:200 渲染标题正文,Cache-Control: max-age=300(§8.3 SEO 要点)") + void article_detailWithCacheControl() throws Exception { + MvcResult result = mockMvc.perform(get("/portal/demo1/a/{id}", articleId)) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "max-age=300")) + .andReturn(); + assertThat(body(result)).contains("开学典礼隆重举行"); + } + + @Test + @DisplayName("文章详情:跨站文章 404(多租户隔离);不存在文章 404") + void article_siteIsolation() throws Exception { + Integer otherSiteId = persistSite("demo2", 2, "第二门户").getId(); + Integer otherCatId = persistCategory(otherSiteId, 0, "别站栏目", CategoryTypeEnum.LIST).getId(); + persistArticle(otherSiteId, otherCatId, "别站文章", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(30)); + + mockMvc.perform(get("/portal/demo1/a/{id}", 999999)).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("文章详情:草稿/下架文章 404(仅已发布可读)") + void article_onlyPublishedVisible() throws Exception { + Integer draftId = persistArticle(siteId, listCatId, "草稿文章", + ArticleStatusEnum.DRAFT, null).getId(); + mockMvc.perform(get("/portal/demo1/a/{id}", draftId)).andExpect(status().isNotFound()); + } + + // ===== 站内搜索 ===== + + @Test + @DisplayName("搜索:关键词命中渲染结果;空关键词空结果页(边界)") + void search_rendersResults() throws Exception { + MvcResult hit = mockMvc.perform(get("/portal/demo1/search").param("q", "开学")) + .andExpect(status().isOk()).andReturn(); + assertThat(body(hit)).contains("开学典礼隆重举行"); + + MvcResult blank = mockMvc.perform(get("/portal/demo1/search").param("q", "")) + .andExpect(status().isOk()).andReturn(); + assertThat(body(blank)).doesNotContain("开学典礼隆重举行"); + } + + // ===== 域名/反代形态(T-1 盲区补齐:方案 §8.6)===== + + @Test + @DisplayName("T-1:配置 public_base_url → canonical/链接/资产全部指向该域(学校域形态)") + void home_publicBaseUrlDrivesAllUrls() throws Exception { + siteRepository.findById(siteId).ifPresent(s -> { + s.setConfigJson("{\"public_base_url\":\"https://www.demo-edu.cn\"}"); + siteRepository.saveAndFlush(s); + }); + clearCaches(); + MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()).andReturn(); + String html = body(result); + assertThat(html).contains("<link rel=\"canonical\" href=\"https://www.demo-edu.cn/\">"); + assertThat(html).contains("https://www.demo-edu.cn/assets/default/css/main.css"); + // 轮播等上传地址同样以该域为基准 + assertThat(html).contains("https://www.demo-edu.cn/upload/cms/banner.png"); + assertThat(html).doesNotContain("http://localhost"); + } + + @Test + @DisplayName("B-2:X-Forwarded-Proto/Host 下派生链接/canonical 用对外 scheme/host(反代形态)") + void home_forwardedHeadersCorrectDerivedBase() throws Exception { + MvcResult result = mockMvc.perform(get("/portal/demo1/") + .header("X-Forwarded-Proto", "https") + .header("X-Forwarded-Host", "proxy.example.com")) + .andExpect(status().isOk()).andReturn(); + assertThat(body(result)).contains("https://proxy.example.com/portal/demo1/"); + } + + @Test + @DisplayName("C-7:og 基本三件——首页 og:title/og:image(轮播首图),详情 og:image=封面(补基准)") + void seo_ogTagsRendered() throws Exception { + MvcResult home = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()).andReturn(); + String homeHtml = body(home); + assertThat(homeHtml).contains("<meta property=\"og:title\" content=\"云海第一中学门户\">"); + assertThat(homeHtml) + .contains("<meta property=\"og:image\" content=\"http://localhost/upload/cms/banner.png\">"); + + articleRepository.findById(articleId).ifPresent(a -> { + a.setCoverImage("/cms/upload/cms/demo1/202609/cover.jpg"); + articleRepository.saveAndFlush(a); + }); + MvcResult detail = mockMvc.perform(get("/portal/demo1/a/{id}", articleId)) + .andExpect(status().isOk()).andReturn(); + assertThat(body(detail)).contains( + "<meta property=\"og:image\" content=\"http://localhost/cms/upload/cms/demo1/202609/cover.jpg\">"); + } + + // ===== 门户根路径 / 主题资产 / CAS 拦截契约 ===== + + @Test + @DisplayName("门户根路径 /portal/:302 跳转首个启用站点(compose 健康探活路径)") + void portalIndex_redirectsToFirstSite() throws Exception { + mockMvc.perform(get("/portal/")) + .andExpect(status().is3xxRedirection()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .contains("/portal/demo1/")); + } + + @Test + @DisplayName("主题资产:皮肤包 CSS 可匿名访问并带 Cache-Control(§8.2/§8.6;B-3 URL 含主题段)") + void themeAsset_servedWithCache() throws Exception { + mockMvc.perform(get("/portal/demo1/assets/default/css/main.css")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "max-age=3600, public")); + } + + @Test + @DisplayName("主题资产:URL 主题段未注册 404(防路径注入/拼错包名)") + void themeAsset_unregisteredTheme404() throws Exception { + mockMvc.perform(get("/portal/demo1/assets/not-a-theme/css/main.css")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("主题资产:路径穿越被拒(../ 拒绝 400/404)") + void themeAsset_rejectsTraversal() throws Exception { + mockMvc.perform(get("/portal/demo1/assets/../../application.yml")) + .andExpect(result -> assertThat( + result.getResponse().getStatus()).isIn(400, 404)); + } + + @Test + @DisplayName("B-3 防回归:换肤后首页资产 URL 随主题变化(缓存与资产 URL 交叉点)") + void themeSwitch_assetUrlChanges() throws Exception { + MvcResult before = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()) + .andReturn(); + assertThat(body(before)).contains("/assets/default/css/main.css"); + + siteRepository.findById(siteId).ifPresent(s -> { + s.setTheme("classic-red"); + siteRepository.saveAndFlush(s); + }); + clearCaches(); + + MvcResult after = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()) + .andReturn(); + assertThat(body(after)).contains("/assets/classic-red/css/main.css"); + assertThat(body(after)).doesNotContain("/assets/default/css/main.css"); + // 新主题段资产真实可达(classic-red 为内置注册皮肤包) + mockMvc.perform(get("/portal/demo1/assets/classic-red/css/main.css")) + .andExpect(status().isOk()); + } + + // ===== 上传资源地址补基准(B-1:webUrl 相对形态 + 渲染期补全)===== + + @Test + @DisplayName("B-1:文章正文相对/历史绝对上传图渲染为当前基准完整 URL(T-3)") + void article_uploadUrlsRewrittenToCurrentOrigin() throws Exception { + Integer artId = persistArticle(siteId, listCatId, "图文文章", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60)).getId(); + articleRepository.findById(artId).ifPresent(a -> { + a.setContent("<p><img src=\"/cms/upload/cms/demo1/202609/rel.jpg\"></p>" + + "<p><img src=\"http://localhost:8072/cms/upload/cms/demo1/202609/legacy.jpg\"></p>" + + "<p><img src=\"https://cdn.example.com/keep.png\"></p>"); + articleRepository.saveAndFlush(a); + }); + + MvcResult result = mockMvc.perform(get("/portal/demo1/a/{id}", artId)) + .andExpect(status().isOk()).andReturn(); + String html = body(result); + // MockMvc 默认形态 origin=http://localhost + assertThat(html).contains("src=\"http://localhost/cms/upload/cms/demo1/202609/rel.jpg\""); + assertThat(html).contains("src=\"http://localhost/cms/upload/cms/demo1/202609/legacy.jpg\""); + assertThat(html).doesNotContain("localhost:8072"); + assertThat(html).contains("src=\"https://cdn.example.com/keep.png\""); + } + + @Test + @DisplayName("B-1:单页栏目内容上传图同样补基准") + void singlePage_uploadUrlsRewritten() throws Exception { + categoryRepository.findById(singleCatId).ifPresent(c -> { + c.setContent("<p><img src=\"/cms/upload/cms/demo1/202609/page.jpg\"></p>"); + categoryRepository.saveAndFlush(c); + }); + MvcResult result = mockMvc.perform(get("/portal/demo1/c/{id}", singleCatId)) + .andExpect(status().isOk()).andReturn(); + assertThat(body(result)) + .contains("src=\"http://localhost/cms/upload/cms/demo1/202609/page.jpg\""); + } + + @Test + @DisplayName("B-1:首页轮播图上传地址补基准为完整 URL") + void home_bannerImageUrlRewritten() throws Exception { + MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()) + .andReturn(); + // 种子轮播 imageUrl=/upload/cms/banner.png → 渲染为 http://localhost/upload/cms/banner.png + assertThat(body(result)).contains("http://localhost/upload/cms/banner.png"); + } + + @Test + @DisplayName("CAS 拦截契约:匿名访问 /api/* 被 302 至 SSO;门户 /portal/* 匿名放行(§7.1)") + void casFilter_interceptsApiButNotPortal() throws Exception { + mockMvc.perform(get("/api/site/list")) + .andExpect(status().is3xxRedirection()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .contains("sso.")); + mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/yunhai/cms/portal/ThemeContractIT.java b/src/test/java/com/yunhai/cms/portal/ThemeContractIT.java new file mode 100644 index 0000000..2b19c8e --- /dev/null +++ b/src/test/java/com/yunhai/cms/portal/ThemeContractIT.java @@ -0,0 +1,105 @@ +package com.yunhai.cms.portal; + +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.theme.ThemeInfo; +import com.yunhai.cms.theme.ThemeRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 皮肤包渲染契约护栏(方案 §8.3 F 组 T22,§4.3 数据契约自动化守护): + * 遍历 Registry 全部已注册主题,五路由断言 200 + 站点名渲染(200 已隐含无模板异常)。 + * P0 对 default+t-demo-red 绿;P2 新增皮肤自动纳入,无需改动本测试。 + */ +@DisplayName("ThemeContract - 皮肤包渲染契约护栏(方案 §4.3)") +class ThemeContractIT extends CmsIntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ThemeRegistry themeRegistry; + + private String siteCode; + private String siteName; + private Integer listCatId; + private Integer singleCatId; + private Integer articleId; + + @BeforeEach + void purgeOnly() { + purgeTables(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + /** 为当前主题种一套完整可渲染数据 */ + private void seedSiteForTheme(String theme) { + siteCode = ("ct-" + theme).toLowerCase().replace('_', '-'); + siteName = "契约站点-" + theme; + com.yunhai.cms.entity.CmsSite site = persistSite(siteCode, 1, siteName); + site.setTheme(theme); + siteRepository.saveAndFlush(site); + + listCatId = persistCategory(site.getId(), 0, "新闻中心", CategoryTypeEnum.LIST).getId(); + singleCatId = persistCategory(site.getId(), 0, "学校简介", CategoryTypeEnum.SINGLE).getId(); + categoryRepository.findById(singleCatId).ifPresent(c -> { + c.setContent("<p>契约站点简介正文。</p>"); + categoryRepository.saveAndFlush(c); + }); + articleId = persistArticle(site.getId(), listCatId, "契约测试文章", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(120)).getId(); + } + + private String body(MvcResult result, String theme, String route) + throws java.io.UnsupportedEncodingException { + String html = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(html).as("主题 %s 的%s应渲染站点名(200 已隐含无模板异常)", theme, route) + .contains(siteName); + return html; + } + + @Test + @DisplayName("T22: 每个注册主题渲染五路由(home/list/single/detail/search 全 200 含站点名)") + void everyRegisteredThemeRendersAllRoutes() throws Exception { + List<ThemeInfo> themes = themeRegistry.list(); + assertThat(themes).as("注册表不可为空(红阶段护栏)").isNotEmpty(); + + for (ThemeInfo theme : themes) { + seedSiteForTheme(theme.getName()); + + body(mockMvc.perform(get("/portal/{code}/", siteCode)) + .andExpect(status().isOk()).andReturn(), theme.getName(), "首页"); + + body(mockMvc.perform(get("/portal/{code}/c/{id}", siteCode, listCatId)) + .andExpect(status().isOk()).andReturn(), theme.getName(), "列表页"); + + body(mockMvc.perform(get("/portal/{code}/c/{id}", siteCode, singleCatId)) + .andExpect(status().isOk()).andReturn(), theme.getName(), "单页"); + + body(mockMvc.perform(get("/portal/{code}/a/{id}", siteCode, articleId)) + .andExpect(status().isOk()).andReturn(), theme.getName(), "详情页"); + + body(mockMvc.perform(get("/portal/{code}/search", siteCode).param("q", "契约")) + .andExpect(status().isOk()).andReturn(), theme.getName(), "搜索页"); + + purgeTables(); + } + } +} diff --git a/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java b/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java new file mode 100644 index 0000000..0ff4c45 --- /dev/null +++ b/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java @@ -0,0 +1,96 @@ +package com.yunhai.cms.portal; + +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 主题微调参数渲染注入(方案 §8.3 C 组 T13-T15,§5.3): + * 站点 config_json.theme_params 经快照装配 → head 内联 :root 覆盖串(位于皮肤 CSS 之后)。 + */ +@DisplayName("ThemeRender - 微调参数 CSS 注入(方案 §5.3)") +class ThemeRenderIT extends CmsIntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + + @BeforeEach + void seed() { + purgeTables(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + private void persistSiteWithConfig(String code, String configJson) { + CmsSite site = new CmsSite(); + site.setCode(code); + site.setSchId(1); + site.setSchName("学校-1"); + site.setName("注入测试门户"); + site.setTheme("default"); + site.setConfigJson(configJson); + site.setStatus(1); + siteRepository.saveAndFlush(site); + } + + private String homeHtml(String siteCode) throws Exception { + MvcResult result = mockMvc.perform(get("/portal/{code}/", siteCode)) + .andExpect(status().isOk()) + .andReturn(); + return result.getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + @Test + @DisplayName("T13: 微调参数注入 CSS 变量且位于皮肤 CSS link 之后") + void themeParamsInjectedAfterSkinCss() throws Exception { + persistSiteWithConfig("inj1", "{\"theme_params\":{\"primary\":\"#b01a2e\"}}"); + + String html = homeHtml("inj1"); + assertThat(html).as("合法色值应注入").contains("--primary:#b01a2e"); + int cssLinkIndex = html.indexOf("css/main.css"); + int varIndex = html.indexOf("--primary:#b01a2e"); + assertThat(cssLinkIndex).as("皮肤 CSS link 应存在").isGreaterThan(-1); + assertThat(varIndex).as("内联覆盖串必须在皮肤 CSS link 之后(层叠后写覆盖)") + .isGreaterThan(cssLinkIndex); + } + + @Test + @DisplayName("T14: 非法色值不注入(red;} / javascript:alert(1) 防注入)") + void illegalColorValuesNotInjected() throws Exception { + persistSiteWithConfig("inj2", "{\"theme_params\":{\"primary\":\"red;}}\"}}"); + + String html = homeHtml("inj2"); + assertThat(html).doesNotContain("red;}}").doesNotContain("red;}"); + assertThat(html).doesNotContain("--primary:red"); + + purgeTables(); + persistSiteWithConfig("inj3", "{\"theme_params\":{\"primary\":\"javascript:alert(1)\"}}"); + String html3 = homeHtml("inj3"); + assertThat(html3).doesNotContain("javascript:alert(1)"); + assertThat(html3).doesNotContain("--primary:javascript"); + } + + @Test + @DisplayName("T15: 未声明 key 不注入(按主题 schema 过滤)") + void undeclaredKeysNotInjected() throws Exception { + persistSiteWithConfig("inj4", "{\"theme_params\":{\"zzhack\":\"zzvalue\"}}"); + + String html = homeHtml("inj4"); + assertThat(html).doesNotContain("zzhack").doesNotContain("zzvalue"); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/ArticleScheduleTaskIT.java b/src/test/java/com/yunhai/cms/service/impl/ArticleScheduleTaskIT.java new file mode 100644 index 0000000..43effc8 --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/ArticleScheduleTaskIT.java @@ -0,0 +1,139 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.event.ArticleChangedEvent; +import com.yunhai.cms.event.ArticlePublishedEvent; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; + +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 定时发布/下架扫描集成测试(方案 §13:@Scheduled 每分钟扫到期记录,无重调度器)。 + * 直接调用扫描方法验证扫描语义;@Scheduled 的周期触发由 Spring 调度保证。 + */ +@DisplayName("ArticleScheduleTask - 定时发布/下架扫描") +class ArticleScheduleTaskIT extends CmsIntegrationTestBase { + + @Autowired + private ArticleScheduleWorker scheduleWorker; + @Autowired + private ConfigurableApplicationContext applicationContext; + + private Integer siteId; + private Integer categoryId; + + /** + * 手工注册一次性事件监听器捕获发布事件:publishEvent(POJO) 会被包装为 + * PayloadApplicationEvent,按载荷类型过滤 + * (本地私服的 spring-boot-test 为裁剪版,无 @RecordApplicationEvents 可用)。 + */ + @SuppressWarnings("unchecked") + private <T> Set<T> recordEvents(Class<T> eventType) { + Set<T> seen = Collections.newSetFromMap(new ConcurrentHashMap<>()); + applicationContext.addApplicationListener((ApplicationListener<ApplicationEvent>) event -> { + Object payload = event instanceof org.springframework.context.PayloadApplicationEvent + ? ((org.springframework.context.PayloadApplicationEvent<?>) event).getPayload() + : event; + if (eventType.isInstance(payload)) { + seen.add((T) payload); + } + }); + return seen; + } + + @BeforeEach + void seed() { + purgeTables(); + siteId = persistSite("demo1", 1, "演示站点一").getId(); + categoryId = persistCategory(siteId, 0, "新闻中心", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + @Test + @DisplayName("定时发布:到期草稿被发布,未到期/非草稿不受影响") + void publishDue_publishesOnlyDueDrafts() { + CmsArticle due = persistArticle(siteId, categoryId, "已到发布时间", + ArticleStatusEnum.DRAFT, nowMinusSeconds(60)); + CmsArticle future = persistArticle(siteId, categoryId, "未到发布时间", + ArticleStatusEnum.DRAFT, nowPlusSeconds(3600)); + CmsArticle published = persistArticle(siteId, categoryId, "已是发布态", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60)); + + scheduleWorker.publishDue(); + + assertThat(articleRepository.findById(due.getId()).orElseThrow(IllegalStateException::new) + .getStatus()).isEqualTo(ArticleStatusEnum.PUBLISHED.getCode()); + assertThat(articleRepository.findById(future.getId()).orElseThrow(IllegalStateException::new) + .getStatus()).isEqualTo(ArticleStatusEnum.DRAFT.getCode()); + assertThat(articleRepository.findById(published.getId()).orElseThrow(IllegalStateException::new) + .getStatus()).isEqualTo(ArticleStatusEnum.PUBLISHED.getCode()); + } + + @Test + @DisplayName("定时发布到期:发布 ArticlePublishedEvent(AFTER_COMMIT,微信 Noop 挂点契约 §7.4)") + void publishDue_firesArticlePublishedEvent() { + CmsArticle due = persistArticle(siteId, categoryId, "事件验证文", + ArticleStatusEnum.DRAFT, nowMinusSeconds(30)); + Set<ArticlePublishedEvent> events = recordEvents(ArticlePublishedEvent.class); + + scheduleWorker.publishDue(); + + assertThat(events.stream() + .filter(e -> due.getId().equals(e.getArticleId()) && "事件验证文".equals(e.getTitle())) + .count()).isEqualTo(1); + } + + @Test + @DisplayName("定时下架:到期已发布文章被下架并发 ArticleChangedEvent;未到期不受影响") + void offlineDue_offlinesOnlyExpired() { + CmsArticle expired = persistArticle(siteId, categoryId, "到期下架", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(3600)); + articleRepository.findById(expired.getId()).ifPresent(a -> { + a.setOfflineTime(nowMinusSeconds(60)); + articleRepository.saveAndFlush(a); + }); + CmsArticle kept = persistArticle(siteId, categoryId, "未到下架时间", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(1800)); + articleRepository.findById(kept.getId()).ifPresent(a -> { + a.setOfflineTime(nowPlusSeconds(3600)); + articleRepository.saveAndFlush(a); + }); + Set<ArticleChangedEvent> events = recordEvents(ArticleChangedEvent.class); + + scheduleWorker.offlineDue(); + + assertThat(articleRepository.findById(expired.getId()).orElseThrow(IllegalStateException::new) + .getStatus()).isEqualTo(ArticleStatusEnum.OFFLINE.getCode()); + assertThat(articleRepository.findById(kept.getId()).orElseThrow(IllegalStateException::new) + .getStatus()).isEqualTo(ArticleStatusEnum.PUBLISHED.getCode()); + assertThat(events.stream() + .filter(e -> expired.getId().equals(e.getArticleId())) + .count()).isEqualTo(1); + } + + @Test + @DisplayName("空扫描:无到期记录时不抛异常(边界)") + void scan_withNoDueRecordsIsNoop() { + scheduleWorker.publishDue(); + scheduleWorker.offlineDue(); + assertThat(articleRepository.count()).isZero(); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/ArticleServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/ArticleServiceIT.java new file mode 100644 index 0000000..59f7f4c --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/ArticleServiceIT.java @@ -0,0 +1,343 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.entity.CmsAttachment; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.model.dto.ArticleSaveRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.query.ArticlePageQuery; +import com.yunhai.cms.model.vo.ArticleEditVo; +import com.yunhai.cms.model.vo.ArticlePageItem; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 文章管理集成测试(方案 §9.1/§9.2:草稿/发布/下架/置顶/定时发布下架 + jsoup 保存时清洗)。 + */ +@DisplayName("ArticleService - 文章管理") +@Transactional +class ArticleServiceIT extends CmsIntegrationTestBase { + + @Autowired + private ArticleService articleService; + @Autowired + private javax.persistence.EntityManager entityManager; + + private Integer siteId; + private Integer categoryId; + + @BeforeEach + void seed() { + siteId = persistSite("demo1", 1, "演示站点一").getId(); + categoryId = persistCategory(siteId, 0, "新闻中心", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + } + + private ArticleSaveRequest saveReq(String title) { + ArticleSaveRequest r = new ArticleSaveRequest(); + r.setSiteId(siteId); + r.setCategoryId(categoryId); + r.setTitle(title); + r.setContent("<p>" + title + " 的正文段落</p>"); + return r; + } + + // ===== 保存(草稿)===== + + @Test + @DisplayName("保存草稿:状态=草稿,正文清洗,摘要缺省自动截取正文") + void save_draftDefaults() { + Integer id = articleService.save(TestUsers.superAdmin(), saveReq("开学典礼")); + CmsArticle article = articleRepository.findById(id).orElseThrow(IllegalStateException::new); + + assertThat(article.getStatus()).isEqualTo(ArticleStatusEnum.DRAFT.getCode()); + assertThat(article.getSiteId()).isEqualTo(siteId); + assertThat(article.getCategoryId()).isEqualTo(categoryId); + assertThat(article.getSummary()).isEqualTo("开学典礼 的正文段落"); + assertThat(article.getTopmost()).isZero(); + } + + @Test + @DisplayName("保存草稿:XSS 正文被清洗(script 剥除)") + void save_contentSanitized() { + ArticleSaveRequest req = saveReq("XSS 测试"); + req.setContent("<p>正常</p><script>alert('x')</script><img src=\"x\" onerror=\"evil()\">"); + Integer id = articleService.save(TestUsers.superAdmin(), req); + + CmsArticle article = articleRepository.findById(id).orElseThrow(IllegalStateException::new); + assertThat(article.getContent()).contains("<p>正常</p>"); + assertThat(article.getContent()).doesNotContain("script").doesNotContain("onerror"); + } + + @Test + @DisplayName("保存草稿:显式摘要优先;长正文截取上限 120 字") + void save_summaryPrecedenceAndCap() { + ArticleSaveRequest explicit = saveReq("显式摘要"); + explicit.setSummary("编辑手工摘要"); + Integer explicitId = articleService.save(TestUsers.superAdmin(), explicit); + assertThat(articleRepository.findById(explicitId).orElseThrow(IllegalStateException::new) + .getSummary()).isEqualTo("编辑手工摘要"); + + StringBuilder longText = new StringBuilder(); + for (int i = 0; i < 30; i++) { + longText.append("这段正文有十个字哦"); + } + ArticleSaveRequest auto = saveReq("自动摘要"); + auto.setContent("<p>" + longText + "</p>"); + Integer autoId = articleService.save(TestUsers.superAdmin(), auto); + assertThat(articleRepository.findById(autoId).orElseThrow(IllegalStateException::new) + .getSummary()).hasSize(120); + } + + @Test + @DisplayName("保存草稿:attachmentIds 回填归属(编辑器图片先传后存场景)") + void save_bindsAttachments() { + CmsAttachment att = new CmsAttachment(); + att.setSiteId(siteId); + att.setFileName("a.jpg"); + att.setFilePath("cms/demo1/202609/x.jpg"); + att.setWebUrl("http://localhost:8072/cms/upload/cms/demo1/202609/x.jpg"); + att = attachmentRepository.saveAndFlush(att); + + ArticleSaveRequest req = saveReq("带图文章"); + req.setAttachmentIds(Collections.singletonList(att.getId())); + Integer articleId = articleService.save(TestUsers.superAdmin(), req); + + // bindArticle 为 JPQL 批量更新,直读一级缓存会拿到旧值,清空后回读库值 + entityManager.clear(); + assertThat(attachmentRepository.findById(att.getId()).orElseThrow(IllegalStateException::new) + .getArticleId()).isEqualTo(articleId); + } + + @Test + @DisplayName("修改他人站点文章:按站点隔离判定文章不存在") + void save_updateArticleOfOtherSiteNotFound() { + Integer otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + Integer otherCatId = persistCategory(otherSiteId, 0, "别站栏目", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + CmsArticle other = persistArticle(otherSiteId, otherCatId, "别站文章", + ArticleStatusEnum.DRAFT, null); + + ArticleSaveRequest hijack = saveReq("越权改文"); + hijack.setId(other.getId()); + assertThatThrownBy(() -> articleService.save(TestUsers.superAdmin(), hijack)) + .isInstanceOf(BizException.class) + .hasMessageContaining("文章不存在"); + } + + @Test + @DisplayName("栏目不属于该站点/栏目不存在:保存被拒") + void save_categoryValidation() { + ArticleSaveRequest badCat = saveReq("跨站栏目"); + badCat.setCategoryId(persistCategory( + persistSite("demo2", 2, "演示站点二").getId(), 0, "别站栏目", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId()); + assertThatThrownBy(() -> articleService.save(TestUsers.superAdmin(), badCat)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于该站点"); + + ArticleSaveRequest missingCat = saveReq("无栏目"); + missingCat.setCategoryId(99999); + assertThatThrownBy(() -> articleService.save(TestUsers.superAdmin(), missingCat)) + .isInstanceOf(BizException.class) + .hasMessageContaining("栏目不存在"); + } + + // ===== 发布 / 定时发布 ===== + + @Test + @DisplayName("立即发布:状态=已发布,publish_time 填充,作者账号/姓名从会话填充") + void publish_immediate() { + Integer id = articleService.save(TestUsers.superAdmin(), saveReq("立即发布")); + articleService.publish(TestUsers.superAdmin(), siteId, id); + + CmsArticle article = articleRepository.findById(id).orElseThrow(IllegalStateException::new); + assertThat(article.getStatus()).isEqualTo(ArticleStatusEnum.PUBLISHED.getCode()); + assertThat(article.getPublishTime()).isNotNull().isBeforeOrEqualTo(LocalDateTime.now()); + assertThat(article.getAuthorAccount()).isEqualTo("admin"); + assertThat(article.getAuthorName()).isEqualTo("平台管理员"); + } + + @Test + @DisplayName("定时发布:publish_time 为未来 → 保持草稿,等待调度触发(方案 §13)") + void publish_scheduledKeepsDraft() { + ArticleSaveRequest req = saveReq("定时发布"); + req.setPublishTime(nowPlusSeconds(3600)); + Integer id = articleService.save(TestUsers.superAdmin(), req); + articleService.publish(TestUsers.superAdmin(), siteId, id); + + CmsArticle article = articleRepository.findById(id).orElseThrow(IllegalStateException::new); + assertThat(article.getStatus()).isEqualTo(ArticleStatusEnum.DRAFT.getCode()); + assertThat(article.getPublishTime()).isAfter(LocalDateTime.now()); + } + + @Test + @DisplayName("作者信息不被后续发布覆盖(已有 authorAccount 时保留)") + void publish_keepsExistingAuthor() { + Integer id = articleService.save(TestUsers.superAdmin(), saveReq("已有作者")); + CmsArticle article = articleRepository.findById(id).orElseThrow(IllegalStateException::new); + article.setAuthorAccount("origin"); + article.setAuthorName("原作者"); + articleRepository.saveAndFlush(article); + + articleService.publish(TestUsers.superAdmin(), siteId, id); + CmsArticle republished = articleRepository.findById(id).orElseThrow(IllegalStateException::new); + assertThat(republished.getAuthorAccount()).isEqualTo("origin"); + assertThat(republished.getAuthorName()).isEqualTo("原作者"); + } + + // ===== 下架 / 置顶 / 删除 ===== + + @Test + @DisplayName("下架:状态=已下架") + void offline_setsStatus() { + Integer id = articleService.save(TestUsers.superAdmin(), saveReq("待下架")); + articleService.publish(TestUsers.superAdmin(), siteId, id); + articleService.offline(TestUsers.superAdmin(), siteId, id); + assertThat(articleRepository.findById(id).orElseThrow(IllegalStateException::new).getStatus()) + .isEqualTo(ArticleStatusEnum.OFFLINE.getCode()); + } + + @Test + @DisplayName("置顶开关:enable=true→1,再关→0") + void topmost_toggle() { + Integer id = articleService.save(TestUsers.superAdmin(), saveReq("置顶文")); + articleService.topmost(TestUsers.superAdmin(), siteId, id, true); + assertThat(articleRepository.findById(id).orElseThrow(IllegalStateException::new).getTopmost()) + .isEqualTo(1); + articleService.topmost(TestUsers.superAdmin(), siteId, id, false); + assertThat(articleRepository.findById(id).orElseThrow(IllegalStateException::new).getTopmost()) + .isZero(); + } + + @Test + @DisplayName("删除:文章删除且附件转孤儿(article_id 置空,方案 §9.1 附件管理)") + void delete_orphansAttachments() { + Integer id = articleService.save(TestUsers.superAdmin(), saveReq("待删文")); + CmsAttachment att = new CmsAttachment(); + att.setSiteId(siteId); + att.setArticleId(id); + att.setFileName("del.jpg"); + att.setFilePath("cms/demo1/202609/d.jpg"); + att.setWebUrl("http://localhost:8072/cms/upload/cms/demo1/202609/d.jpg"); + attachmentRepository.saveAndFlush(att); + + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(id); + articleService.delete(TestUsers.superAdmin(), del); + + assertThat(articleRepository.findById(id)).isEmpty(); + // unbindArticle 为 JPQL 批量更新,清一级缓存后回读库值 + entityManager.clear(); + assertThat(attachmentRepository.findById(att.getId()) + .orElseThrow(IllegalStateException::new).getArticleId()).isNull(); + } + + @Test + @DisplayName("操作他站文章抛 FORBIDDEN(多租户)") + void operations_otherSiteForbidden() { + Integer otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + Integer otherCatId = persistCategory(otherSiteId, 0, "别站栏目", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + Integer otherArticleId = persistArticle(otherSiteId, otherCatId, "别站文章", + ArticleStatusEnum.DRAFT, null).getId(); + + assertThatThrownBy(() -> articleService.publish( + TestUsers.superAdmin(), siteId, otherArticleId)) + .isInstanceOf(BizException.class) + .hasMessageContaining("文章不存在"); + + // 校级用户(学校2)不能操作学校1 的站点 + Integer ownId = articleService.save(TestUsers.schoolUser(1), saveReq("本校文")); + assertThatThrownBy(() -> articleService.offline(TestUsers.schoolUser(2), siteId, ownId)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + // ===== 分页查询 ===== + + @Test + @DisplayName("分页:按状态/关键字/栏目过滤,置顶优先排序") + @SuppressWarnings("unchecked") + void page_filtersAndOrdering() { + Integer subCatId = persistCategory(siteId, categoryId, "子栏目", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + persistArticle(siteId, categoryId, "普通甲", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(300)); + CmsArticle top = persistArticle(siteId, categoryId, "置顶乙", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(200)); + top.setTopmost(1); + articleRepository.saveAndFlush(top); + persistArticle(siteId, subCatId, "子栏目文", ArticleStatusEnum.DRAFT, null); + persistArticle(persistSite("demo2", 2, "演示站点二").getId(), + persistCategory(99999, 0, "x", com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(), + "别站文", ArticleStatusEnum.PUBLISHED, null); + + ArticlePageQuery query = new ArticlePageQuery(); + query.setSiteId(siteId); + Map<String, Object> all = articleService.page(TestUsers.superAdmin(), query); + assertThat(all.get("total")).isEqualTo(3L); // 别站文被隔离 + + List<ArticlePageItem> list = (List<ArticlePageItem>) all.get("list"); + assertThat(list).extracting(ArticlePageItem::getTitle) + .containsExactly("置顶乙", "普通甲", "子栏目文"); // 置顶优先 + createTime 倒序 + + query.setStatus(ArticleStatusEnum.PUBLISHED.getCode()); + assertThat((List<ArticlePageItem>) articleService.page(TestUsers.superAdmin(), query).get("list")) + .extracting(ArticlePageItem::getTitle).containsExactly("置顶乙", "普通甲"); + + query.setStatus(null); + query.setKeyword("普通"); + assertThat((List<ArticlePageItem>) articleService.page(TestUsers.superAdmin(), query).get("list")) + .extracting(ArticlePageItem::getTitle).containsExactly("普通甲"); + + query.setKeyword(null); + query.setCategoryIds(Collections.singletonList(subCatId)); + assertThat((List<ArticlePageItem>) articleService.page(TestUsers.superAdmin(), query).get("list")) + .extracting(ArticlePageItem::getTitle).containsExactly("子栏目文"); + } + + @Test + @DisplayName("分页边界:page/pageSize 非法值回退默认(page=1,size=10)") + void page_invalidPagingFallsBack() { + for (int i = 0; i < 12; i++) { + persistArticle(siteId, categoryId, "批量文" + i, ArticleStatusEnum.DRAFT, null); + } + ArticlePageQuery query = new ArticlePageQuery(); + query.setSiteId(siteId); + query.setPage(0); + query.setPageSize(-5); + Map<String, Object> result = articleService.page(TestUsers.superAdmin(), query); + assertThat(result.get("total")).isEqualTo(12L); + assertThat(((List<?>) result.get("list"))).hasSize(10); + } + + // ===== 编辑回显 ===== + + @Test + @DisplayName("getEdit:按站点隔离,回显清洗后的正文") + void getEdit_siteScopedEcho() { + Integer id = articleService.save(TestUsers.superAdmin(), saveReq("回显文")); + ArticleEditVo vo = articleService.getEdit(TestUsers.superAdmin(), siteId, id); + assertThat(vo.getTitle()).isEqualTo("回显文"); + assertThat(vo.getContent()).contains("回显文 的正文段落"); + + assertThatThrownBy(() -> articleService.getEdit(TestUsers.superAdmin(), siteId, 99999)) + .isInstanceOf(BizException.class) + .hasMessageContaining("文章不存在"); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java new file mode 100644 index 0000000..e40fc4d --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java @@ -0,0 +1,249 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsAttachment; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.vo.AttachmentVo; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 附件上传/管理集成测试(方案 §7.3 双 key 约定 / §8.5 白名单;A-3 图片双校验)。 + * 上传落盘到 ./target/test-upload/(test yml FILE_PATH),测试后随 mvn clean 清除。 + */ +@DisplayName("AttachmentService - 附件上传与管理") +class AttachmentServiceIT extends CmsIntegrationTestBase { + + @Autowired + private AttachmentService attachmentService; + + private Integer siteId; + + @BeforeEach + void seed() { + purgeTables(); + siteId = persistSite("demo1", 1, "演示站点一").getId(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + private MockMultipartFile file(String name, String contentType) { + return new MockMultipartFile("file", name, contentType, "fake-file-bytes".getBytes()); + } + + /** 1x1 真实图片字节(ImageIO 生成,jpg/png/gif/bmp 四格式均可解) */ + private byte[] tinyImage(String format) { + try { + BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(img, format, out); + return out.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private MockMultipartFile imageFile(String format) { + // 浏览器对 .jpg 实发 image/jpeg(规范 MIME),其余格式与扩展名同名 + String mimeType = "jpg".equals(format) ? "image/jpeg" : "image/" + format; + return new MockMultipartFile("file", "图." + format, mimeType, tinyImage(format)); + } + + /** webp 魔数头(RIFF....WEBP),JDK8 ImageIO 无 reader,服务端按魔数校验 */ + private byte[] webpBytes() { + return new byte[]{ + 'R', 'I', 'F', 'F', 0x20, 0, 0, 0, 'W', 'E', 'B', 'P', + 'V', 'P', '8', ' ', 0x0a, 0, 0, 0}; + } + + /** ico 魔数头(reserved=0 type=1 count=1)+ 目录项骨架 */ + private byte[] icoBytes() { + return new byte[]{ + 0, 0, 1, 0, 1, 0, 16, 16, 0, 0, 1, 0, 32, 0, + 0, 0, 0, 0, 0, 0, 0, 0}; + } + + // ===== 正常流程 ===== + + @Test + @DisplayName("上传图片:按 cms/{siteCode}/{yyyyMM}/{uuid32}.ext 落盘并落库,webUrl 拼接") + void upload_imageStoresUnderSiteMonthDir() { + AttachmentVo vo = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, imageFile("jpg")); + com.yunhai.cms.entity.CmsAttachment entity = attachmentRepository.findById(vo.getId()) + .orElseThrow(IllegalStateException::new); + + assertThat(vo.getFileName()).isEqualTo("图.jpg"); + String yyyyMm = java.time.LocalDateTime.now().format( + java.time.format.DateTimeFormatter.ofPattern("yyyyMM")); + assertThat(entity.getFilePath()).startsWith("cms/demo1/" + yyyyMm + "/").endsWith(".jpg"); + // uuid32 重命名:文件名 32 位十六进制 + 后缀(禁止原名/可执行后缀,方案 §8.5) + assertThat(entity.getFilePath().substring(entity.getFilePath().lastIndexOf('/') + 1)) + .matches("[0-9a-f]{32}\\.jpg"); + // B-1:webUrl 一律相对路径入库(禁止绝对地址 → 非 localhost 访问断图),渲染期补基准 + assertThat(vo.getUrl()).startsWith("/cms/upload/").endsWith(entity.getFilePath()); + assertThat(vo.getUrl()).doesNotContain("://"); + assertThat(vo.getMimeType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("上传文档与压缩包:白名单类型全部放行(方案 §7.3)") + void upload_whitelistedDocumentTypes() { + for (String name : new String[]{"通知.pdf", "数据.xlsx", "材料.zip", "讲话.mp4", "说明.txt"}) { + assertThat(attachmentService.upload( + TestUsers.superAdmin(), siteId, null, file(name, "application/octet-stream")) + .getId()).isNotNull(); + } + } + + @Test + @DisplayName("附件列表:orphanOnly 过滤孤儿并返回 orphanCount") + @SuppressWarnings("unchecked") + void page_orphanFilterAndCount() { + attachmentService.upload(TestUsers.superAdmin(), siteId, 1, imageFile("jpg")); + attachmentService.upload(TestUsers.superAdmin(), siteId, null, imageFile("png")); + + Map<String, Object> all = attachmentService.page(TestUsers.superAdmin(), siteId, false, 1, 10); + assertThat(all.get("total")).isEqualTo(2L); + assertThat(all.get("orphanCount")).isEqualTo(1L); + + Map<String, Object> orphans = attachmentService.page(TestUsers.superAdmin(), siteId, true, 1, 10); + assertThat(orphans.get("total")).isEqualTo(1L); + List<AttachmentVo> list = (List<AttachmentVo>) orphans.get("list"); + assertThat(list).extracting(AttachmentVo::getFileName).containsExactly("图.png"); + } + + // ===== 白名单与安全(方案 §8.5:禁止可执行后缀;svg 防脚本)===== + + @Test + @DisplayName("上传可执行/脚本类后缀被拒(exe/bat/svg/无后缀/双后缀伪装)") + void upload_dangerousExtensionsRejected() { + for (String name : new String[]{"木马.exe", "脚本.bat", "图.svg"}) { + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, file(name, "application/octet-stream"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不支持的文件类型"); + } + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, file("noext", "application/octet-stream"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不支持的文件类型"); + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, file("伪装.jpg.exe", "image/jpeg"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不支持的文件类型"); + } + + @Test + @DisplayName("A-3 图片双校验:伪装图片被拒(exe 改名 .jpg / 非 image Content-Type / 文本伪装 png)") + void upload_disguisedImageRejected() { + // .exe 内容(MZ 头)改名 .jpg 且 Content-Type 伪装成 image/jpeg → 魔数校验拒绝 + byte[] exeBytes = new byte[64]; + exeBytes[0] = 'M'; + exeBytes[1] = 'Z'; + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, + new MockMultipartFile("file", "照片.jpg", "image/jpeg", exeBytes))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不是有效图片"); + + // 真实图片字节 + application/octet-stream → Content-Type 校验拒绝 + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, + new MockMultipartFile("file", "照片.jpg", "application/octet-stream", tinyImage("jpg")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("Content-Type"); + + // 纯文本改名 .png(Content-Type 伪装到位)→ 魔数校验拒绝 + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, + new MockMultipartFile("file", "文档.png", "image/png", + "not-an-image".getBytes(StandardCharsets.UTF_8)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不是有效图片"); + + // webp 后缀但魔数头错误 → 拒绝 + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, + new MockMultipartFile("file", "动图.webp", "image/webp", + "RIFFxxxxGIF8".getBytes(StandardCharsets.UTF_8)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不是有效图片"); + } + + @Test + @DisplayName("A-3 图片双校验:真实图片全部放行(jpg/png/gif/bmp ImageIO 可解;webp/ico 魔数头)") + void upload_realImagesAccepted() { + for (String format : new String[]{"jpg", "png", "gif", "bmp"}) { + assertThat(attachmentService.upload( + TestUsers.superAdmin(), siteId, null, imageFile(format)).getId()).isNotNull(); + } + assertThat(attachmentService.upload(TestUsers.superAdmin(), siteId, null, + new MockMultipartFile("file", "动图.webp", "image/webp", webpBytes())) + .getId()).isNotNull(); + assertThat(attachmentService.upload(TestUsers.superAdmin(), siteId, null, + new MockMultipartFile("file", "图标.ico", "image/x-icon", icoBytes())) + .getId()).isNotNull(); + } + + @Test + @DisplayName("空文件/不存在站点/越权站点上传被拒(异常分支)") + void upload_invalidInputsRejected() { + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, new MockMultipartFile( + "file", "空.jpg", "image/jpeg", new byte[0]))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不能为空"); + + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), 99999, null, file("a.jpg", "image/jpeg"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("站点不存在"); + + // 学校2 编辑不能向学校1 站点上传(多租户) + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.schoolUser(2), siteId, null, file("越权.jpg", "image/jpeg"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("删除附件记录:本站成功(物理文件留待运维清理),他站 FORBIDDEN") + void delete_siteScoped() { + Integer attId = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, imageFile("jpg")).getId(); + + DeleteRequest wrongSite = new DeleteRequest(); + wrongSite.setSiteId(persistSite("demo2", 2, "演示站点二").getId()); + wrongSite.setId(attId); + assertThatThrownBy(() -> attachmentService.delete(TestUsers.superAdmin(), wrongSite)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(attId); + attachmentService.delete(TestUsers.superAdmin(), del); + assertThat(attachmentRepository.findById(attId)).isEmpty(); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/BannerServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/BannerServiceIT.java new file mode 100644 index 0000000..5123135 --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/BannerServiceIT.java @@ -0,0 +1,213 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsBanner; +import com.yunhai.cms.model.dto.BannerSaveRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.vo.BannerVo; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 轮播/推荐位管理集成测试(方案 §5.1 表5 / §9.2:保存校验 link_type/link_value)。 + */ +@DisplayName("BannerService - 轮播管理") +@Transactional +class BannerServiceIT extends CmsIntegrationTestBase { + + @Autowired + private BannerService bannerService; + + private Integer siteId; + private Integer categoryId; + private Integer articleId; + + @BeforeEach + void seed() { + siteId = persistSite("demo1", 1, "演示站点一").getId(); + categoryId = persistCategory(siteId, 0, "新闻中心", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + articleId = persistArticle(siteId, categoryId, "轮播目标文章", + com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60)).getId(); + } + + private BannerSaveRequest req(int linkType, String linkValue) { + BannerSaveRequest r = new BannerSaveRequest(); + r.setSiteId(siteId); + r.setTitle("首页轮播一"); + r.setImageUrl("/upload/cms/demo1/202609/b.png"); + r.setLinkType(linkType); + r.setLinkValue(linkValue); + r.setPosition("home"); + return r; + } + + // ===== 正常流程 ===== + + @Test + @DisplayName("保存轮播:文章/栏目/外链三种跳转类型均接受合法目标") + void save_allLinkTypesAccepted() { + assertThat(bannerService.save(TestUsers.superAdmin(), req(1, String.valueOf(articleId))).getId()) + .isNotNull(); + assertThat(bannerService.save(TestUsers.superAdmin(), req(2, String.valueOf(categoryId))).getId()) + .isNotNull(); + assertThat(bannerService.save(TestUsers.superAdmin(), req(3, "https://www.example.com")).getId()) + .isNotNull(); + } + + @Test + @DisplayName("保存轮播:时间窗(上线/下线)与默认值生效") + void save_timeWindowAndDefaults() { + BannerSaveRequest r = req(3, "https://www.example.com"); + LocalDateTime start = nowMinusSeconds(60); + LocalDateTime end = nowPlusSeconds(3600); + r.setStartTime(start); + r.setEndTime(end); + BannerVo vo = bannerService.save(TestUsers.superAdmin(), r); + assertThat(vo.getStartTime()).isEqualTo(start); + assertThat(vo.getEndTime()).isEqualTo(end); + assertThat(vo.getStatus()).isEqualTo(1); + } + + @Test + @DisplayName("列表:按展示位过滤;不传 position 返回全部(含停用)") + void list_positionFilter() { + bannerService.save(TestUsers.superAdmin(), req(3, "https://www.example.com")); + BannerSaveRequest side = req(3, "https://www.example.com"); + side.setPosition("side"); + bannerService.save(TestUsers.superAdmin(), side); + BannerSaveRequest disabled = req(3, "https://www.example.com"); + disabled.setStatus(0); + bannerService.save(TestUsers.superAdmin(), disabled); + + List<BannerVo> homeOnly = bannerService.list(TestUsers.superAdmin(), siteId, "home"); + assertThat(homeOnly).hasSize(2); // 含停用条(管理端全量) + assertThat(bannerService.list(TestUsers.superAdmin(), siteId, "side")).hasSize(1); + assertThat(bannerService.list(TestUsers.superAdmin(), siteId, null)).hasSize(3); + } + + // ===== link_type/link_value 校验(方案 §9.2 banner save 校验)===== + + @Test + @DisplayName("外链类型:非 http(s):// 协议被拒") + void save_externalProtocolValidated() { + assertThatThrownBy(() -> bannerService.save( + TestUsers.superAdmin(), req(3, "ftp://files.example.com"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("http"); + assertThatThrownBy(() -> bannerService.save( + TestUsers.superAdmin(), req(3, "javascript:alert(1)"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("http"); + assertThatThrownBy(() -> bannerService.save(TestUsers.superAdmin(), req(3, " "))) + .isInstanceOf(BizException.class) + .hasMessageContaining("http"); + } + + @Test + @DisplayName("文章类型:非数字目标被拒") + void save_articleTargetMustBeNumeric() { + assertThatThrownBy(() -> bannerService.save( + TestUsers.superAdmin(), req(1, "not-a-number"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("数字"); + } + + @Test + @DisplayName("文章类型:目标文章不存在或属他站被拒(禁止跨站引用)") + void save_articleTargetMustExistInSite() { + assertThatThrownBy(() -> bannerService.save(TestUsers.superAdmin(), req(1, "99999"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不存在"); + + Integer otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + Integer otherCatId = persistCategory(otherSiteId, 0, "别站栏目", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + Integer otherArticleId = persistArticle(otherSiteId, otherCatId, "别站文章", + com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED, null).getId(); + assertThatThrownBy(() -> bannerService.save( + TestUsers.superAdmin(), req(1, String.valueOf(otherArticleId)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不存在"); + } + + @Test + @DisplayName("栏目类型:目标栏目属他站被拒") + void save_categoryTargetMustBeInSite() { + Integer otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + Integer otherCatId = persistCategory(otherSiteId, 0, "别站栏目", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + assertThatThrownBy(() -> bannerService.save( + TestUsers.superAdmin(), req(2, String.valueOf(otherCatId)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不存在"); + } + + // ===== 其他异常分支 ===== + + @Test + @DisplayName("下线时间早于上线时间被拒") + void save_endBeforeStartRejected() { + BannerSaveRequest r = req(3, "https://www.example.com"); + r.setStartTime(nowPlusSeconds(3600)); + r.setEndTime(nowPlusSeconds(1800)); + assertThatThrownBy(() -> bannerService.save(TestUsers.superAdmin(), r)) + .isInstanceOf(BizException.class) + .hasMessageContaining("早于"); + } + + @Test + @DisplayName("修改/删除他站轮播抛 FORBIDDEN") + void crossSiteForbidden() { + BannerVo own = bannerService.save(TestUsers.superAdmin(), req(3, "https://www.example.com")); + Integer otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + + BannerSaveRequest hijack = req(3, "https://www.example.com"); + hijack.setId(own.getId()); + hijack.setSiteId(otherSiteId); + assertThatThrownBy(() -> bannerService.save(TestUsers.superAdmin(), hijack)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + + DeleteRequest del = new DeleteRequest(); + del.setSiteId(otherSiteId); + del.setId(own.getId()); + assertThatThrownBy(() -> bannerService.delete(TestUsers.superAdmin(), del)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("删除本站轮播成功") + void delete_ownBannerSucceeds() { + CmsBanner banner = bannerRepository.saveAndFlush(newBanner(siteId, "home")); + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(banner.getId()); + bannerService.delete(TestUsers.superAdmin(), del); + assertThat(bannerRepository.findById(banner.getId())).isEmpty(); + } + + private CmsBanner newBanner(Integer siteId, String position) { + CmsBanner b = new CmsBanner(); + b.setSiteId(siteId); + b.setPosition(position); + b.setTitle("直建轮播"); + b.setImageUrl("/upload/b.png"); + b.setLinkType(3); + b.setLinkValue("https://www.example.com"); + b.setStatus(1); + return b; + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/CategoryServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/CategoryServiceIT.java new file mode 100644 index 0000000..75d31b3 --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/CategoryServiceIT.java @@ -0,0 +1,261 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.model.dto.CategorySaveRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.vo.CategoryTreeNode; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 栏目树管理集成测试(方案 §9.1 栏目管理 / §5.1 表2)。 + * 事务回滚隔离:每个用例独立、不污染库表。 + */ +@DisplayName("CategoryService - 栏目树管理") +@Transactional +class CategoryServiceIT extends CmsIntegrationTestBase { + + @Autowired + private CategoryService categoryService; + @Autowired + private ArticleService articleService; + + private Integer siteId; + + @BeforeEach + void seedSite() { + siteId = persistSite("demo1", 1, "演示站点一").getId(); + } + + private CategorySaveRequest req(String name, Integer parentId, Integer type) { + CategorySaveRequest r = new CategorySaveRequest(); + r.setSiteId(siteId); + r.setParentId(parentId); + r.setName(name); + r.setType(type); + r.setSortNo(0); + return r; + } + + // ===== 正常流程 ===== + + @Test + @DisplayName("保存根栏目/子栏目:返回树节点,tree 组装层级与排序正确") + void save_andTreeAssembly() { + CategoryTreeNode news = categoryService.save(TestUsers.superAdmin(), req("新闻中心", 0, 1)); + CategoryTreeNode notice = categoryService.save(TestUsers.superAdmin(), req("通知公告", 0, 1)); + categoryService.save(TestUsers.superAdmin(), req("校园动态", news.getId(), 1)); + categoryService.save(TestUsers.superAdmin(), req("媒体聚焦", news.getId(), 1)); + + List<CategoryTreeNode> tree = categoryService.tree(TestUsers.superAdmin(), siteId); + assertThat(tree).hasSize(2); + assertThat(tree).extracting(CategoryTreeNode::getName) + .containsExactly("新闻中心", "通知公告"); // sortNo 同为 0 时按 id 升序 + CategoryTreeNode newsNode = tree.get(0); + assertThat(newsNode.getChildren()).extracting(CategoryTreeNode::getName) + .containsExactly("校园动态", "媒体聚焦"); + } + + @Test + @DisplayName("排序生效:sortNo 升序展示") + void tree_respectsSortNo() { + CategorySaveRequest b = req("乙", 0, 1); + b.setSortNo(2); + categoryService.save(TestUsers.superAdmin(), b); + CategorySaveRequest a = req("甲", 0, 1); + a.setSortNo(1); + categoryService.save(TestUsers.superAdmin(), a); + + List<CategoryTreeNode> tree = categoryService.tree(TestUsers.superAdmin(), siteId); + assertThat(tree).extracting(CategoryTreeNode::getName).containsExactly("甲", "乙"); + } + + @Test + @DisplayName("单页栏目:content 经 jsoup 清洗后入库") + void save_singlePageContentSanitized() { + CategorySaveRequest intro = req("学校简介", 0, 2); + intro.setContent("<p>学校简介正文</p><script>alert(1)</script>"); + CategoryTreeNode node = categoryService.save(TestUsers.superAdmin(), intro); + + List<CategoryTreeNode> tree = categoryService.tree(TestUsers.superAdmin(), siteId); + assertThat(tree.get(0).getContent()).contains("学校简介正文").doesNotContain("script"); + assertThat(node.getId()).isNotNull(); + } + + @Test + @DisplayName("外链栏目:合法 linkUrl 保存成功") + void save_linkCategoryWithUrl() { + CategorySaveRequest moe = req("教育部", 0, 3); + moe.setLinkUrl("https://www.moe.gov.cn"); + CategoryTreeNode node = categoryService.save(TestUsers.superAdmin(), moe); + assertThat(node.getType()).isEqualTo(3); + assertThat(node.getLinkUrl()).isEqualTo("https://www.moe.gov.cn"); + } + + @Test + @DisplayName("管理端树包含隐藏栏目(门户侧才过滤,方案 §9.1 树形增删改)") + void tree_adminSideIncludesHidden() { + CategorySaveRequest hidden = req("隐藏栏目", 0, 1); + hidden.setStatus(0); + categoryService.save(TestUsers.superAdmin(), hidden); + + List<CategoryTreeNode> tree = categoryService.tree(TestUsers.superAdmin(), siteId); + assertThat(tree).extracting(CategoryTreeNode::getName).contains("隐藏栏目"); + } + + // ===== 异常分支 ===== + + @Test + @DisplayName("外链栏目未填 linkUrl 被拒") + void save_linkCategoryWithoutUrlRejected() { + assertThatThrownBy(() -> categoryService.save(TestUsers.superAdmin(), req("空外链", 0, 3))) + .isInstanceOf(BizException.class) + .hasMessageContaining("外链"); + } + + @Test + @DisplayName("父栏目不能是自身") + void save_selfParentRejected() { + CategoryTreeNode a = categoryService.save(TestUsers.superAdmin(), req("自引用", 0, 1)); + CategorySaveRequest update = req("自引用", a.getId(), 1); + update.setId(a.getId()); + assertThatThrownBy(() -> categoryService.save(TestUsers.superAdmin(), update)) + .isInstanceOf(BizException.class) + .hasMessageContaining("自身"); + } + + @Test + @DisplayName("父栏目设为自己的子栏目(成环)被拒") + void save_cycleRejected() { + CategoryTreeNode parent = categoryService.save(TestUsers.superAdmin(), req("父", 0, 1)); + CategoryTreeNode child = categoryService.save( + TestUsers.superAdmin(), req("子", parent.getId(), 1)); + + CategorySaveRequest reverse = req("父", child.getId(), 1); + reverse.setId(parent.getId()); + assertThatThrownBy(() -> categoryService.save(TestUsers.superAdmin(), reverse)) + .isInstanceOf(BizException.class) + .hasMessageContaining("成环"); + } + + @Test + @DisplayName("跨站点父栏目被拒(多租户隔离)") + void save_crossSiteParentRejected() { + Integer otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + CategoryTreeNode otherRoot = categoryService.save( + TestUsers.superAdmin(), req("别站栏目", 0, 1)); + // otherRoot 属于 demo1(本站);构造"父栏目属于另一站点"场景需先在 demo2 建栏目 + CategorySaveRequest otherReq = req("demo2根栏目", 0, 1); + otherReq.setSiteId(otherSiteId); + CategoryTreeNode otherSiteRoot = categoryService.save(TestUsers.superAdmin(), otherReq); + + CategorySaveRequest bad = req("挂别站子栏目", otherSiteRoot.getId(), 1); + assertThatThrownBy(() -> categoryService.save(TestUsers.superAdmin(), bad)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于该站点"); + + assertThat(otherSiteRoot.getId()).isNotNull(); + assertThat(otherRoot.getId()).isNotNull(); + } + + @Test + @DisplayName("修改他站栏目抛 FORBIDDEN(siteId 与栏目归属不匹配)") + void save_crossSiteUpdateForbidden() { + Integer otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + CategorySaveRequest otherReq = req("demo2栏目", 0, 1); + otherReq.setSiteId(otherSiteId); + CategoryTreeNode otherNode = categoryService.save(TestUsers.superAdmin(), otherReq); + + CategorySaveRequest hijack = req("越权改名", 0, 1); + hijack.setId(otherNode.getId()); + assertThatThrownBy(() -> categoryService.save(TestUsers.superAdmin(), hijack)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("父栏目不存在被拒(边界)") + void save_missingParentRejected() { + assertThatThrownBy(() -> categoryService.save( + TestUsers.superAdmin(), req("孤儿", 99999, 1))) + .isInstanceOf(BizException.class) + .hasMessageContaining("父栏目不存在"); + } + + @Test + @DisplayName("校级用户对他站站点操作栏目被拒(入口权限)") + void save_schoolUserOtherSiteForbidden() { + assertThatThrownBy(() -> categoryService.save( + TestUsers.schoolUser(2), req("越权栏目", 0, 1))) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + // ===== 删除(校验空栏目,方案 §9.2)===== + + @Test + @DisplayName("删除空栏目成功") + void delete_emptyCategorySucceeds() { + CategoryTreeNode node = categoryService.save(TestUsers.superAdmin(), req("待删", 0, 1)); + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(node.getId()); + categoryService.delete(TestUsers.superAdmin(), del); + assertThat(categoryRepository.findById(node.getId())).isEmpty(); + } + + @Test + @DisplayName("存在子栏目时删除被拒") + void delete_categoryWithChildrenRejected() { + CategoryTreeNode parent = categoryService.save(TestUsers.superAdmin(), req("有子", 0, 1)); + categoryService.save(TestUsers.superAdmin(), req("子", parent.getId(), 1)); + + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(parent.getId()); + assertThatThrownBy(() -> categoryService.delete(TestUsers.superAdmin(), del)) + .isInstanceOf(BizException.class) + .hasMessageContaining("子栏目"); + } + + @Test + @DisplayName("栏目下存在文章时删除被拒(文章可先迁移/删除)") + void delete_categoryWithArticlesRejected() { + CategoryTreeNode cat = categoryService.save(TestUsers.superAdmin(), req("有文", 0, 1)); + persistArticle(siteId, cat.getId(), "占用文章", + com.yunhai.cms.enums.ArticleStatusEnum.DRAFT, null); + + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(cat.getId()); + assertThatThrownBy(() -> categoryService.delete(TestUsers.superAdmin(), del)) + .isInstanceOf(BizException.class) + .hasMessageContaining("文章"); + } + + @Test + @DisplayName("删除他站栏目抛 FORBIDDEN") + void delete_crossSiteForbidden() { + Integer otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + CategorySaveRequest otherReq = req("demo2待删", 0, 1); + otherReq.setSiteId(otherSiteId); + CategoryTreeNode otherNode = categoryService.save(TestUsers.superAdmin(), otherReq); + + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(otherNode.getId()); + assertThatThrownBy(() -> categoryService.delete(TestUsers.superAdmin(), del)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/DirtyValidationIT.java b/src/test/java/com/yunhai/cms/service/impl/DirtyValidationIT.java new file mode 100644 index 0000000..f6ad958 --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/DirtyValidationIT.java @@ -0,0 +1,174 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.entity.CmsBanner; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.model.dto.ArticleSaveRequest; +import com.yunhai.cms.model.dto.BannerSaveRequest; +import com.yunhai.cms.model.dto.CategorySaveRequest; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 脏数据健壮性回归(第 2 步 bug 清单 D-3/D-4/D-5/D-6): + * 非法枚举码显式拒绝、定时时间窗校验、删除时轮播引用拦截(统一"被引用即拦"规则)。 + */ +@DisplayName("脏数据校验 - 枚举/时间窗/删除引用拦截") +@Transactional +class DirtyValidationIT extends CmsIntegrationTestBase { + + @Autowired + private ArticleService articleService; + @Autowired + private CategoryService categoryService; + @Autowired + private BannerService bannerService; + + private Integer siteId; + private Integer categoryId; + + @BeforeEach + void seed() { + siteId = persistSite("dirty1", 1, "脏数据演示站").getId(); + categoryId = persistCategory(siteId, 0, "新闻", CategoryTypeEnum.LIST).getId(); + } + + private CategorySaveRequest catReq(Integer type) { + CategorySaveRequest r = new CategorySaveRequest(); + r.setSiteId(siteId); + r.setParentId(0); + r.setName("新栏目"); + r.setType(type); + return r; + } + + // ===== D-4 非法枚举码 ===== + + @Test + @DisplayName("栏目 type=9 显式拒绝(不再静默降级为列表栏目)") + void categorySave_illegalType_rejected() { + CategorySaveRequest r = catReq(9); + assertThatThrownBy(() -> categoryService.save(TestUsers.superAdmin(), r)) + .isInstanceOf(BizException.class) + .hasMessageContaining("栏目类型非法"); + } + + @Test + @DisplayName("轮播 linkType=9 显式拒绝(不再误报\"跳转目标必须是数字 ID\")") + void bannerSave_illegalLinkType_rejected() { + BannerSaveRequest r = new BannerSaveRequest(); + r.setSiteId(siteId); + r.setPosition("home"); + r.setTitle("t"); + r.setImageUrl("/upload/x.png"); + r.setLinkType(9); + r.setLinkValue("https://example.cn"); + assertThatThrownBy(() -> bannerService.save(TestUsers.superAdmin(), r)) + .isInstanceOf(BizException.class) + .hasMessageContaining("跳转类型非法"); + } + + // ===== D-5 定时时间窗 ===== + + @Test + @DisplayName("保存:offlineTime 早于 publishTime 显式拒绝(状态机自洽)") + void articleSave_offlineBeforePublish_rejected() { + ArticleSaveRequest r = new ArticleSaveRequest(); + r.setSiteId(siteId); + r.setCategoryId(categoryId); + r.setTitle("时间矛盾"); + r.setPublishTime(LocalDateTime.of(2026, 12, 1, 0, 0)); + r.setOfflineTime(LocalDateTime.of(2026, 11, 1, 0, 0)); + assertThatThrownBy(() -> articleService.save(TestUsers.superAdmin(), r)) + .isInstanceOf(BizException.class) + .hasMessageContaining("定时下架时间必须晚于发布时间"); + } + + @Test + @DisplayName("发布:过期 offlineTime 自愈清空(重发=新生命周期,不再被调度器立即下架)") + void publish_staleOfflineTime_cleared() { + Integer id = articleService.save(TestUsers.superAdmin(), draftReq()); + // 直接落库一个矛盾历史数据(模拟脏数据):offlineTime 已过 + articleRepository.findById(id).ifPresent(a -> { + a.setOfflineTime(LocalDateTime.now().minusDays(1)); + articleRepository.saveAndFlush(a); + }); + articleService.publish(TestUsers.superAdmin(), siteId, id); + CmsArticle after = articleRepository.findById(id).orElseThrow(IllegalStateException::new); + assertThat(after.getStatus()).isEqualTo(ArticleStatusEnum.PUBLISHED.getCode()); + assertThat(after.getOfflineTime()).isNull(); + } + + private ArticleSaveRequest draftReq() { + ArticleSaveRequest r = new ArticleSaveRequest(); + r.setSiteId(siteId); + r.setCategoryId(categoryId); + r.setTitle("普通草稿"); + r.setContent("<p>正文</p>"); + return r; + } + + // ===== D-6 删除时轮播引用拦截 ===== + + @Test + @DisplayName("删除文章:被轮播引用时拦截并提示") + void articleDelete_blockedByBannerReference() { + Integer articleId = articleService.save(TestUsers.superAdmin(), draftReq()); + persistBannerRef(1, String.valueOf(articleId)); + DeleteRequest req = new DeleteRequest(); + req.setSiteId(siteId); + req.setId(articleId); + assertThatThrownBy(() -> articleService.delete(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("轮播"); + } + + @Test + @DisplayName("删除栏目:被轮播引用时拦截并提示(与子栏目/文章拦截同一规则)") + void categoryDelete_blockedByBannerReference() { + persistBannerRef(2, String.valueOf(categoryId)); + DeleteRequest req = new DeleteRequest(); + req.setSiteId(siteId); + req.setId(categoryId); + assertThatThrownBy(() -> categoryService.delete(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("轮播"); + } + + @Test + @DisplayName("删除文章:无轮播引用时正常删除(不误拦)") + void articleDelete_withoutReference_succeeds() { + Integer articleId = articleService.save(TestUsers.superAdmin(), draftReq()); + DeleteRequest req = new DeleteRequest(); + req.setSiteId(siteId); + req.setId(articleId); + articleService.delete(TestUsers.superAdmin(), req); + assertThat(articleRepository.findById(articleId)).isEmpty(); + } + + private void persistBannerRef(int linkType, String linkValue) { + CmsBanner b = new CmsBanner(); + b.setSiteId(siteId); + b.setPosition("home"); + b.setTitle("引用型轮播"); + b.setImageUrl("/upload/banner.png"); + b.setLinkType(linkType); + b.setLinkValue(linkValue); + b.setSortNo(1); + b.setStatus(1); + bannerRepository.saveAndFlush(b); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/LinkServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/LinkServiceIT.java new file mode 100644 index 0000000..430e280 --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/LinkServiceIT.java @@ -0,0 +1,139 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.model.dto.LinkSaveRequest; +import com.yunhai.cms.model.vo.LinkVo; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 友情链接管理集成测试(方案 §5.1 表6 / §9.2)。 + */ +@DisplayName("LinkService - 友情链接管理") +@Transactional +class LinkServiceIT extends CmsIntegrationTestBase { + + @Autowired + private LinkService linkService; + + private Integer siteId; + + @BeforeEach + void seed() { + siteId = persistSite("demo1", 1, "演示站点一").getId(); + } + + private LinkSaveRequest req(String name, String url) { + LinkSaveRequest r = new LinkSaveRequest(); + r.setSiteId(siteId); + r.setName(name); + r.setLinkUrl(url); + return r; + } + + @Test + @DisplayName("保存友链:http/https/站内相对路径均接受;分组与默认值生效") + void save_urlSchemesAndDefaults() { + assertThat(linkService.save(TestUsers.superAdmin(), req("教育部", "http://www.moe.gov.cn")).getId()) + .isNotNull(); + assertThat(linkService.save(TestUsers.superAdmin(), req("兄弟院校", "https://www.pku.edu.cn")).getId()) + .isNotNull(); + + LinkVo relative = linkService.save(TestUsers.superAdmin(), req("站内页", "/cms/portal/demo1/")); + assertThat(relative.getGroupName()).isEqualTo("default"); // 分组缺省 + assertThat(relative.getSortNo()).isZero(); + assertThat(relative.getStatus()).isEqualTo(1); + } + + @Test + @DisplayName("保存友链:javascript: 等非法协议与空地址被拒") + void save_invalidUrlRejected() { + assertThatThrownBy(() -> linkService.save( + TestUsers.superAdmin(), req("危险链", "javascript:alert(1)"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("http"); + assertThatThrownBy(() -> linkService.save(TestUsers.superAdmin(), req("空链", ""))) + .isInstanceOf(BizException.class) + .hasMessageContaining("http"); + assertThatThrownBy(() -> linkService.save( + TestUsers.superAdmin(), req("数据协议", "data:text/html,<script>"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("http"); + } + + @Test + @DisplayName("列表按分组名+组内排序返回全量(含隐藏,供管理端)") + void list_orderedByGroupAndSort() { + LinkSaveRequest b = req("B链", "https://b.example.com"); + b.setGroupName("上级部门"); + b.setSortNo(2); + linkService.save(TestUsers.superAdmin(), b); + + LinkSaveRequest a = req("A链", "https://a.example.com"); + a.setGroupName("上级部门"); + a.setSortNo(1); + linkService.save(TestUsers.superAdmin(), a); + + LinkSaveRequest coop = req("合作单位链", "https://c.example.com"); + coop.setGroupName("合作单位"); + linkService.save(TestUsers.superAdmin(), coop); + + LinkSaveRequest hidden = req("隐藏链", "https://h.example.com"); + hidden.setStatus(0); + linkService.save(TestUsers.superAdmin(), hidden); + + List<LinkVo> list = linkService.list(TestUsers.superAdmin(), siteId); + // 分组名升序走 MySQL utf8mb4_general_ci:ASCII "default" < 中文(上 U+4E0A < 合 U+5408) + assertThat(list).extracting(LinkVo::getName) + .containsExactly("隐藏链", "A链", "B链", "合作单位链"); + } + + @Test + @DisplayName("修改/删除他站友链抛 FORBIDDEN;删除本站成功") + void crossSiteAndDelete() { + LinkVo own = linkService.save(TestUsers.superAdmin(), req("待删链", "https://x.example.com")); + Integer otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + + LinkSaveRequest hijack = req("越权改链", "https://y.example.com"); + hijack.setId(own.getId()); + hijack.setSiteId(otherSiteId); + assertThatThrownBy(() -> linkService.save(TestUsers.superAdmin(), hijack)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + + DeleteRequest wrongSite = new DeleteRequest(); + wrongSite.setSiteId(otherSiteId); + wrongSite.setId(own.getId()); + assertThatThrownBy(() -> linkService.delete(TestUsers.superAdmin(), wrongSite)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(own.getId()); + linkService.delete(TestUsers.superAdmin(), del); + assertThat(linkRepository.findById(own.getId())).isEmpty(); + } + + @Test + @DisplayName("删除不存在的友链报错(边界)") + void delete_missingRejected() { + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(99999); + assertThatThrownBy(() -> linkService.delete(TestUsers.superAdmin(), del)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不存在"); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/PortalQueryServiceCacheIT.java b/src/test/java/com/yunhai/cms/service/impl/PortalQueryServiceCacheIT.java new file mode 100644 index 0000000..1e4f52c --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/PortalQueryServiceCacheIT.java @@ -0,0 +1,260 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.config.CacheNames; +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.event.SiteContentChangedEvent; +import com.yunhai.cms.model.portal.PortalArticleDetail; +import com.yunhai.cms.model.portal.PortalListData; +import com.yunhai.cms.model.portal.PortalSiteSnapshot; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.context.ApplicationEventPublisher; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 门户缓存语义集成测试(方案 §8.4 缓存设计)。 + * 非事务用例:事件 AFTER_COMMIT 真实触发、缓存写入/失效全链路验证。 + */ +@DisplayName("PortalQueryService - 门户缓存语义(方案 §8.4)") +class PortalQueryServiceCacheIT extends CmsIntegrationTestBase { + + @Autowired + private PortalQueryService portalQueryService; + @Autowired + private ArticleService articleService; + @Autowired + private SiteService siteService; + @Autowired + private ApplicationEventPublisher eventPublisher; + + private Integer siteId; + private Integer categoryId; + + @BeforeEach + void seed() { + purgeTables(); + siteId = persistSite("demo1", 1, "演示站点一").getId(); + categoryId = persistCategory(siteId, 0, "新闻中心", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + // ===== cms:site 快照(30min TTL)===== + + @Test + @DisplayName("站点快照缓存命中:落库改名后快照仍旧值,站点变更事件后恢复新值") + void siteSnapshot_cachedUntilEventEvicts() { + PortalSiteSnapshot first = portalQueryService.siteSnapshot("demo1"); + assertThat(first.getName()).isEqualTo("演示站点一"); + assertThat(first.getCategories()).hasSize(1); + + // 直接改库(绕过业务事件),验证快照确实来自缓存 + siteRepository.findById(siteId).ifPresent(s -> { + s.setName("改名后的站点"); + siteRepository.saveAndFlush(s); + }); + PortalSiteSnapshot stale = portalQueryService.siteSnapshot("demo1"); + assertThat(stale.getName()).isEqualTo("演示站点一"); + + // 站点/栏目/轮播/友链变更事件(AFTER_COMMIT 契约,无事务上下文 fallback 立即执行) + eventPublisher.publishEvent(new SiteContentChangedEvent(siteId, "site")); + PortalSiteSnapshot fresh = portalQueryService.siteSnapshot("demo1"); + assertThat(fresh.getName()).isEqualTo("改名后的站点"); + } + + @Test + @DisplayName("快照内容:只含启用栏目/轮播/友链,隐藏项排除在门户之外") + void siteSnapshot_filtersDisabledEntries() { + Integer hiddenCat = persistCategory(siteId, 0, "隐藏栏目", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + categoryRepository.findById(hiddenCat).ifPresent(c -> { + c.setStatus(0); + categoryRepository.saveAndFlush(c); + }); + persistBanner(siteId, "home", "启用轮播", nowMinusSeconds(60), nowPlusSeconds(3600)); + persistBanner(siteId, "home", "停用轮播", null, null).getId(); + bannerRepository.findAll().stream() + .filter(b -> "停用轮播".equals(b.getTitle())) + .findFirst() + .ifPresent(b -> { + b.setStatus(0); + bannerRepository.saveAndFlush(b); + }); + persistLink(siteId, "上级部门", "教育部"); + persistLink(siteId, "上级部门", "隐藏友链").getId(); + linkRepository.findAll().stream() + .filter(l -> "隐藏友链".equals(l.getName())) + .findFirst() + .ifPresent(l -> { + l.setStatus(0); + linkRepository.saveAndFlush(l); + }); + + PortalSiteSnapshot snapshot = portalQueryService.siteSnapshot("demo1"); + assertThat(snapshot.getCategories()) + .extracting(c -> c.getName()).containsExactly("新闻中心"); + assertThat(snapshot.getBanners()).extracting(b -> b.getTitle()) + .containsExactly("启用轮播"); + assertThat(snapshot.getLinkGroups()).hasSize(1); + assertThat(snapshot.getLinkGroups().get(0).getLinks()) + .extracting(l -> l.getName()).containsExactly("教育部"); + } + + @Test + @DisplayName("null 哨兵:不存在/停用站点返回 null 并写入哨兵缓存(防穿透,30s TTL)") + void siteSnapshot_nullSentinelForMissingOrDisabled() { + assertThat(portalQueryService.siteSnapshot("no-such")).isNull(); + Cache.ValueWrapper sentinel = cacheManager.getCache(CacheNames.SITE).get("no-such"); + assertThat(sentinel).isNotNull(); // NullValue 哨兵已入缓存 + + // 停用站点同样返回 null(方案 §5.1 site.status) + siteRepository.findById(siteId).ifPresent(s -> { + s.setStatus(0); + siteRepository.saveAndFlush(s); + }); + assertThat(portalQueryService.siteSnapshot("demo1")).isNull(); + } + + // ===== cms:article 详情(5min TTL,null 哨兵)===== + + @Test + @DisplayName("文章详情:仅已发布可读;发布→修改→下架全链路缓存随事件失效") + void articleDetail_lifecycleWithCacheEviction() { + CmsArticle draft = persistArticle(siteId, categoryId, "草稿不可见", + ArticleStatusEnum.DRAFT, null); + assertThat(portalQueryService.articleDetail(draft.getId())).isNull(); + + // 发布(真实事务提交 → ArticlePublishedEvent → AFTER_COMMIT 失效详情+列表) + articleService.publish(TestUsers.superAdmin(), siteId, draft.getId()); + PortalArticleDetail published = portalQueryService.articleDetail(draft.getId()); + assertThat(published).isNotNull(); + assertThat(published.getTitle()).isEqualTo("草稿不可见"); + assertThat(published.getSiteId()).isEqualTo(siteId); + + // 下架(事务提交 → ArticleChangedEvent → 缓存失效 → 门户立即不可见) + articleService.offline(TestUsers.superAdmin(), siteId, draft.getId()); + assertThat(portalQueryService.articleDetail(draft.getId())).isNull(); + } + + @Test + @DisplayName("文章详情:不存在的文章缓存 null 哨兵") + void articleDetail_missingCachedAsSentinel() { + assertThat(portalQueryService.articleDetail(999999)).isNull(); + assertThat(cacheManager.getCache(CacheNames.ARTICLE).get(999999)).isNotNull(); + } + + // ===== cms:list 列表(5min TTL,站点文章发布/下架后整缓存失效)===== + + @Test + @DisplayName("栏目列表缓存:发布新文章触发 LIST 清空,列表页立即出现新文章") + void categoryPage_invalidatedOnPublish() { + persistArticle(siteId, categoryId, "已有文章", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(120)); + PortalListData first = portalQueryService.categoryPage(categoryId, 1, 10); + assertThat(first.getItems()).extracting(i -> i.getTitle()) + .containsExactly("已有文章"); + + // 缓存命中:再查不变(无新增) + assertThat(portalQueryService.categoryPage(categoryId, 1, 10).getTotal()).isEqualTo(1L); + + // 新发布一篇文章(真实提交 → ArticlePublishedEvent → clear cms:list) + CmsArticle second = persistArticle(siteId, categoryId, "新发文章", + ArticleStatusEnum.DRAFT, null); + articleService.publish(TestUsers.superAdmin(), siteId, second.getId()); + + PortalListData after = portalQueryService.categoryPage(categoryId, 1, 10); + assertThat(after.getItems()).extracting(i -> i.getTitle()) + .containsExactly("新发文章", "已有文章"); // 置顶同为0 → publishTime desc + assertThat(after.getTotal()).isEqualTo(2L); + } + + @Test + @DisplayName("栏目列表:置顶优先 + 分页参数与页对象元数据") + void categoryPage_orderingAndPaging() { + CmsArticle top = persistArticle(siteId, categoryId, "旧但置顶", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(600)); + top.setTopmost(1); + articleRepository.saveAndFlush(top); + persistArticle(siteId, categoryId, "新但不置顶", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60)); + CmsArticle draft = persistArticle(siteId, categoryId, "草稿不出现", + ArticleStatusEnum.DRAFT, null); + persistArticle(siteId, categoryId, "下架不出现", + ArticleStatusEnum.OFFLINE, nowMinusSeconds(30)); + + PortalListData page1 = portalQueryService.categoryPage(categoryId, 1, 1); + assertThat(page1.getTotal()).isEqualTo(2L); // 仅已发布 + assertThat(page1.getTotalPages()).isEqualTo(2); + assertThat(page1.getItems()).extracting(i -> i.getTitle()).containsExactly("旧但置顶"); + + PortalListData page2 = portalQueryService.categoryPage(categoryId, 2, 1); + assertThat(page2.getItems()).extracting(i -> i.getTitle()).containsExactly("新但不置顶"); + + assertThat(draft).isNotNull(); + } + + @Test + @DisplayName("不存在的栏目:返回 null 并缓存哨兵(爬虫扫随机 ID 防穿透)") + void categoryPage_missingNullSentinel() { + assertThat(portalQueryService.categoryPage(999999, 1, 10)).isNull(); + assertThat(cacheManager.getCache(CacheNames.LIST).get("999999:1")).isNotNull(); + } + + @Test + @DisplayName("T17: 改 theme_params 后门户立即生效(保存事件驱逐 cms:site,快照携带新值)") + void siteSnapshot_themeParamsRefreshedAfterSave() { + assertThat(portalQueryService.siteSnapshot("demo1").getThemeParams()) + .as("初始快照无微调参数").isEmpty(); + + com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest(); + req.setId(siteId); + req.setCode("demo1"); + req.setSchId(1); + req.setSchName("学校-1"); + req.setName("演示站点一"); + java.util.Map<String, String> params = new java.util.LinkedHashMap<>(); + params.put("primary", "#b01a2e"); + req.setThemeParams(params); + siteService.save(TestUsers.superAdmin(), req); + + PortalSiteSnapshot fresh = portalQueryService.siteSnapshot("demo1"); + assertThat(fresh.getThemeParams()).as("保存事件驱逐快照后应携带新参数") + .containsEntry("primary", "#b01a2e"); + assertThat(fresh.getThemeVarsCss()).contains("--primary:#b01a2e"); + } + + // ===== 站内搜索(实时不缓存)===== + + @Test + @DisplayName("搜索:title/summary 命中已发布文章;LIKE 通配符按字面匹配不放大") + void search_likeBehavior() { + persistArticle(siteId, categoryId, "开学典礼隆重举行", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60)); + CmsArticle summaryHit = persistArticle(siteId, categoryId, "通知文件", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(50)); + summaryHit.setSummary("关于开学典礼的安排说明"); + articleRepository.saveAndFlush(summaryHit); + persistArticle(siteId, categoryId, "草稿不算", + ArticleStatusEnum.DRAFT, null); + + assertThat(portalQueryService.search(siteId, "开学典礼", 1, 10).getTotalElements()) + .isEqualTo(2L); // 标题命中 + 摘要命中 + assertThat(portalQueryService.search(siteId, "不存在关键词", 1, 10).getTotalElements()) + .isZero(); + // 关键词为 % / _ 时按字面匹配(Containing 转义),不匹配全部 + assertThat(portalQueryService.search(siteId, "%", 1, 10).getTotalElements()).isZero(); + assertThat(portalQueryService.search(siteId, "_", 1, 10).getTotalElements()).isZero(); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/RedisCacheEvolutionIT.java b/src/test/java/com/yunhai/cms/service/impl/RedisCacheEvolutionIT.java new file mode 100644 index 0000000..8d5fc61 --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/RedisCacheEvolutionIT.java @@ -0,0 +1,157 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.config.CacheNames; +import com.yunhai.cms.event.ArticleChangedEvent; +import com.yunhai.cms.event.SiteContentChangedEvent; +import com.yunhai.cms.event.listener.PortalCacheEvictListener; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 「缓存切 Redis 仅改配置」演进承诺验证(方案 §4.1/§8.4/§12)。 + * + * <p>以本地 Redis(见 src/test/resources/application.yml 的 cms.test.redis)手工装配 + * RedisCacheManager + 同一套 CacheNames / PortalCacheEvictListener(生产代码零改动), + * 验证缓存写入/命中/失效链路在 Redis 形态下同样成立——即 §12「学校数>5 切 Redis 改配置」 + * 的可行性背书。普通 JUnit(不启动 Spring 上下文)。 + */ +@DisplayName("Redis 缓存演进验证(方案 §12:切 Redis 仅改配置)") +class RedisCacheEvolutionIT { + + /** 与 src/test/resources/application.yml 的 cms.test.redis 一致 */ + private static final String REDIS_HOST = "localhost"; + private static final int REDIS_PORT = 6379; + private static final String REDIS_PASSWORD = "123456"; + + private static LettuceConnectionFactory connectionFactory; + private static CacheManager cacheManager; + private static PortalCacheEvictListener listener; + + @BeforeAll + static void setUpRedisCacheManager() { + RedisStandaloneConfiguration standalone = new RedisStandaloneConfiguration(REDIS_HOST, REDIS_PORT); + standalone.setPassword(RedisPassword.of(REDIS_PASSWORD)); + connectionFactory = new LettuceConnectionFactory(standalone); + connectionFactory.afterPropertiesSet(); + + // 与 CacheConfig 的 TTL 对齐:site 30min,article/list 5min(null 哨兵由 Spring NullValue 承担) + cacheManager = RedisCacheManager.builder(connectionFactory) + .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5))) + .withCacheConfiguration(CacheNames.SITE, + RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(30))) + .build(); + ((RedisCacheManager) cacheManager).afterPropertiesSet(); + listener = new PortalCacheEvictListener(cacheManager); + clearTestCaches(); + } + + @AfterAll + static void tearDown() { + clearTestCaches(); + connectionFactory.destroy(); + } + + /** 只清理本用例写入的 cms:* 键,不动本地 Redis 其他数据 */ + private static void clearTestCaches() { + for (String name : cacheManager.getCacheNames()) { + Cache cache = cacheManager.getCache(name); + if (cache != null) { + cache.clear(); + } + } + } + + private boolean keyExistsInRedis(String cacheName, String key) { + byte[] redisKey = (cacheName + "::" + key).getBytes(); + try (RedisConnection connection = connectionFactory.getConnection()) { + return Boolean.TRUE.equals(connection.exists(redisKey)); + } + } + + // ===== 缓存读写契约(与 Caffeine 形态一致)===== + + @Test + @DisplayName("CacheNames 三缓存写入 Redis 后可命中,键形态 cacheName::key") + void putAndGet_roundTripOnRedis() { + Cache site = cacheManager.getCache(CacheNames.SITE); + site.put("demo", "快照值"); + assertThat(site.get("demo").get()).isEqualTo("快照值"); + assertThat(keyExistsInRedis(CacheNames.SITE, "demo")).isTrue(); + + Cache article = cacheManager.getCache(CacheNames.ARTICLE); + article.put(1, "详情值"); + assertThat(article.get(1).get()).isEqualTo("详情值"); + + Cache list = cacheManager.getCache(CacheNames.LIST); + list.put("1:1", "列表值"); + assertThat(list.get("1:1").get()).isEqualTo("列表值"); + } + + @Test + @DisplayName("null 哨兵:put(key,null) 落 NullValue,get 取值为 null 且键存在(防穿透契约)") + void nullValueSentinelWorksOnRedis() { + Cache article = cacheManager.getCache(CacheNames.ARTICLE); + article.put(404, null); + + // 与 Caffeine 形态一致:ValueWrapper 非空、取值为 null(应用视角无真实数据) + Cache.ValueWrapper wrapper = article.get(404); + assertThat(wrapper).isNotNull(); + assertThat(wrapper.get()).isNull(); + assertThat(keyExistsInRedis(CacheNames.ARTICLE, "404")).isTrue(); // Redis 里哨兵键存在 + } + + // ===== 失效监听契约(生产代码 PortalCacheEvictListener 直接复用)===== + + @Test + @DisplayName("文章变更事件:精确 evict 详情键 + 整体清空列表缓存(方案 §8.4 失效事件)") + void articleChangedEvent_evictsArticleAndClearsList() { + cacheManager.getCache(CacheNames.ARTICLE).put(7, "旧详情"); + cacheManager.getCache(CacheNames.LIST).put("1:1", "旧列表"); + assertThat(keyExistsInRedis(CacheNames.ARTICLE, "7")).isTrue(); + + listener.onArticleChanged(new ArticleChangedEvent(7, 1)); + + assertThat(keyExistsInRedis(CacheNames.ARTICLE, "7")).isFalse(); + assertThat(keyExistsInRedis(CacheNames.LIST, "1:1")).isFalse(); + } + + @Test + @DisplayName("站点内容变更事件:清空站点快照与列表缓存") + void siteContentChangedEvent_clearsSiteAndList() { + cacheManager.getCache(CacheNames.SITE).put("demo", "旧快照"); + cacheManager.getCache(CacheNames.LIST).put("1:1", "旧列表"); + + listener.onSiteContentChanged(new SiteContentChangedEvent(1, "banner")); + + assertThat(keyExistsInRedis(CacheNames.SITE, "demo")).isFalse(); + assertThat(keyExistsInRedis(CacheNames.LIST, "1:1")).isFalse(); + } + + @Test + @DisplayName("文章发布事件:同样 evict 详情 + 清空列表(微信挂点前置的缓存契约)") + void articlePublishedEvent_sameEviction() { + cacheManager.getCache(CacheNames.ARTICLE).put(9, "发布前详情"); + cacheManager.getCache(CacheNames.LIST).put("1:2", "发布前列表"); + + listener.onArticlePublished( + new com.yunhai.cms.event.ArticlePublishedEvent(9, 1, "标题")); + + assertThat(keyExistsInRedis(CacheNames.ARTICLE, "9")).isFalse(); + assertThat(keyExistsInRedis(CacheNames.LIST, "1:2")).isFalse(); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/SchoolServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/SchoolServiceIT.java new file mode 100644 index 0000000..9f715f1 --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/SchoolServiceIT.java @@ -0,0 +1,81 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.model.vo.SchoolVo; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 学校工作台集成测试(docs/09 §3.3,D6-4): + * 学校卡片 = 顶层站点按 parentSiteId 聚合子站;权限复用 SiteService.listFor 语义。 + */ +@DisplayName("SchoolService - 学校工作台聚合与学校选项") +class SchoolServiceIT extends CmsIntegrationTestBase { + + @Autowired + private SchoolService schoolService; + + @AfterEach + void clean() { + purgeTables(); + } + + @Test + @DisplayName("超管:每个顶层站一张卡,子站数与文章数聚合正确,院系子站不单独成卡") + void listFor_superAdminAggregatesTopSites() { + CmsSite main1 = persistSite("schu1", 9, "云海大学门户"); + CmsSite main2 = persistSite("schu2", 10, "另一大学门户"); + CmsSite child = persistSite("jdx", 9, "机电学院", main1.getId()); + persistArticle(main1.getId(), 0, "主站文章一", ArticleStatusEnum.PUBLISHED, null); + persistArticle(main1.getId(), 0, "主站文章二", ArticleStatusEnum.DRAFT, null); + persistArticle(child.getId(), 0, "子站文章", ArticleStatusEnum.PUBLISHED, null); + + List<SchoolVo> cards = schoolService.listFor(TestUsers.superAdmin()); + + assertThat(cards).as("院系子站不得单独成卡").hasSize(2); + SchoolVo card1 = cards.stream().filter(c -> "schu1".equals(c.getMainSiteCode())).findFirst().get(); + assertThat(card1.getSchId()).isEqualTo(9); + assertThat(card1.getSchName()).isEqualTo("学校-9"); + assertThat(card1.getSiteCount()).as("主站+1院系子站").isEqualTo(2); + assertThat(card1.getArticleCount()).as("主站2篇+子站1篇(管理端口径含未发布)").isEqualTo(3); + assertThat(card1.getStatus()).isEqualTo(1); + + SchoolVo card2 = cards.stream().filter(c -> "schu2".equals(c.getMainSiteCode())).findFirst().get(); + assertThat(card2.getSiteCount()).isEqualTo(1); + assertThat(card2.getArticleCount()).isZero(); + } + + @Test + @DisplayName("校级角色:仅见本校顶层站卡片;无角色用户为空") + void listFor_schoolScopeAndPlainUser() { + persistSite("schu1", 9, "云海大学门户"); + persistSite("schu2", 10, "另一大学门户"); + + List<SchoolVo> own = schoolService.listFor(TestUsers.schoolUser(9)); + assertThat(own).extracting(SchoolVo::getMainSiteCode).containsExactly("schu1"); + + assertThat(schoolService.listFor(TestUsers.plainUser())).isEmpty(); + } + + @Test + @DisplayName("学校选项:非超管 FORBIDDEN;超管在平台不可达(测试环境无 UserBase)降级空数组不抛错") + void options_permissionAndDegradation() { + assertThatThrownBy(() -> schoolService.schoolOptions(TestUsers.schoolUser(9))) + .isInstanceOf(BizException.class) + .hasMessageContaining("超管"); + + // 测试上下文无可达 UserBase:getGroupTree 失败 → 降级空数组(CmsUserCenterService 同款语义) + assertThat(schoolService.schoolOptions(TestUsers.superAdmin())).isEmpty(); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java new file mode 100644 index 0000000..5933b7a --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java @@ -0,0 +1,478 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.model.dto.SiteSaveRequest; +import com.yunhai.cms.model.vo.SiteVo; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 站点与校级权限集成测试(方案 §7.2 权限规则 MVP)。 + */ +@DisplayName("SiteService - 站点与校级权限") +class SiteServiceIT extends CmsIntegrationTestBase { + + @Autowired + private SiteService siteService; + + @AfterEach + void cleanSiteData() { + purgeTables(); + } + + private void seedTwoSchools() { + persistSite("sch1", 1, "第一中学门户"); + persistSite("sch2", 2, "第二中学门户"); + } + + // ===== 权限矩阵(方案 §7.2:超管全量 / 校级本校 / 无角色空)===== + + @Test + @DisplayName("超管(super-role-ids 命中)可见全部站点") + void listFor_superAdminSeesAllSites() { + seedTwoSchools(); + List<SiteVo> sites = siteService.listFor(TestUsers.superAdmin()); + assertThat(sites).extracting(SiteVo::getCode).containsExactlyInAnyOrder("sch1", "sch2"); + } + + @Test + @DisplayName("校级角色(currentRole.schId)仅见本校站点") + void listFor_schoolUserSeesOwnSiteOnly() { + seedTwoSchools(); + List<SiteVo> sites = siteService.listFor(TestUsers.schoolUser(2)); + assertThat(sites).extracting(SiteVo::getCode).containsExactly("sch2"); + } + + @Test + @DisplayName("无角色/无 currentRole 用户站点列表为空") + void listFor_plainUserSeesNothing() { + seedTwoSchools(); + assertThat(siteService.listFor(TestUsers.plainUser())).isEmpty(); + } + + @Test + @DisplayName("requireManageable:超管任意站点通过,返回站点实体") + void requireManageable_superAdminPassesAnySite() { + seedTwoSchools(); + CmsSite site1 = siteService.getByCode("sch1"); + CmsSite site2 = siteService.getByCode("sch2"); + assertThat(siteService.requireManageable(TestUsers.superAdmin(), site1.getId()).getCode()) + .isEqualTo("sch1"); + assertThat(siteService.requireManageable(TestUsers.superAdmin(), site2.getId()).getCode()) + .isEqualTo("sch2"); + } + + @Test + @DisplayName("requireManageable:校级用户访问他校站点抛 FORBIDDEN") + void requireManageable_schoolUserForbiddenOnOtherSchool() { + seedTwoSchools(); + CmsSite site1 = siteService.getByCode("sch1"); + Integer site1Id = site1.getId(); + assertThatThrownBy(() -> siteService.requireManageable(TestUsers.schoolUser(2), site1Id)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("requireManageable:本校站点通过;schId 为空的站点校级用户不可管理") + void requireManageable_schoolUserOwnSitePasses_nullSchIdForbidden() { + seedTwoSchools(); + CmsSite nosch = persistSite("nosch", null, "未挂学校站点"); + Integer ownId = siteService.getByCode("sch2").getId(); + + assertThat(siteService.requireManageable(TestUsers.schoolUser(2), ownId).getCode()) + .isEqualTo("sch2"); + + Integer noschId = nosch.getId(); + assertThatThrownBy(() -> siteService.requireManageable(TestUsers.schoolUser(2), noschId)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("requireManageable:站点不存在/空 siteId 报参数错误(边界)") + void requireManageable_missingSiteRejected() { + seedTwoSchools(); + assertThatThrownBy(() -> siteService.requireManageable(TestUsers.superAdmin(), 99999)) + .isInstanceOf(BizException.class) + .hasMessageContaining("站点不存在"); + assertThatThrownBy(() -> siteService.requireManageable(TestUsers.superAdmin(), null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("站点不存在"); + } + + // ===== 站点保存(仅超管兜底入口,方案 §9.1)===== + + @Test + @DisplayName("超管建站:合法入参成功,config_json 支持 public_base_url 保留键") + void save_superAdminCreatesSite() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("newedu"); + req.setSchId(7); + req.setSchName("第七中学"); + req.setName("第七中学门户"); + req.setConfigJson("{\"domain\":\"www.qizhong.edu.cn\"," + + "\"public_base_url\":\"https://www.qizhong.edu.cn\"}"); + SiteVo vo = siteService.save(TestUsers.superAdmin(), req); + + assertThat(vo.getId()).isNotNull(); + assertThat(vo.getTheme()).isEqualTo("default"); + assertThat(siteService.configValue(siteService.getByCode("newedu"), "public_base_url")) + .isEqualTo("https://www.qizhong.edu.cn"); + } + + @Test + @DisplayName("非超管建站抛 FORBIDDEN(异常分支)") + void save_nonSuperAdminForbidden() { + seedTwoSchools(); + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("hack"); + req.setName("越权站点"); + assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(1), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("超管"); + } + + @Test + @DisplayName("站点编码重复被拒(唯一性)") + void save_duplicateCodeRejected() { + seedTwoSchools(); + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("sch1"); + req.setName("重复编码站点"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("已存在"); + } + + @Test + @DisplayName("config_json 非法 JSON / JSON 数组被拒;空值与空对象放行(边界)") + void save_configJsonValidation() { + SiteSaveRequest base = new SiteSaveRequest(); + base.setCode("cfg1"); + base.setSchId(1); + base.setName("配置校验站"); + + base.setConfigJson("这不是json{"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), base)) + .isInstanceOf(BizException.class) + .hasMessageContaining("JSON"); + + base.setConfigJson("[1,2,3]"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), base)) + .isInstanceOf(BizException.class) + .hasMessageContaining("JSON"); + + base.setConfigJson(null); + assertThat(siteService.save(TestUsers.superAdmin(), base)).isNotNull(); + + SiteSaveRequest compactEmpty = new SiteSaveRequest(); + compactEmpty.setCode("cfg2"); + compactEmpty.setSchId(1); + compactEmpty.setName("空对象配置站"); + compactEmpty.setConfigJson("{}"); + assertThat(siteService.save(TestUsers.superAdmin(), compactEmpty)).isNotNull(); + } + + // ===== 主题白名单与 themeParams 合并(方案 §5.1/§5.3,T9-T12)===== + + @Test + @DisplayName("T9: 保存未注册 theme 被白名单拒绝(当前直接入库 → 红)") + void save_unregisteredThemeRejected() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("theme1"); + req.setSchId(1); + req.setName("白名单校验站"); + req.setTheme("not-exist"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("皮肤"); + } + + @Test + @DisplayName("T10: 保存已注册 theme(夹具 t-demo-red)成功且回显一致") + void save_registeredThemeAccepted() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("theme2"); + req.setSchId(1); + req.setName("夹具主题站"); + req.setTheme("t-demo-red"); + SiteVo vo = siteService.save(TestUsers.superAdmin(), req); + assertThat(vo.getTheme()).isEqualTo("t-demo-red"); + assertThat(siteService.getByCode("theme2").getTheme()).isEqualTo("t-demo-red"); + } + + @Test + @DisplayName("T11: themeParams 服务端合并进 config_json.theme_params 且保留其余键") + void save_themeParamsMergePreservesOtherConfigKeys() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("theme3"); + req.setSchId(1); + req.setName("参数合并站"); + req.setConfigJson("{\"logo\":\"/upload/cms/logo.png\",\"contact\":\"0571-88888888\"}"); + Map<String, String> params = new LinkedHashMap<>(); + params.put("primary", "#ff0000"); + req.setThemeParams(params); + + siteService.save(TestUsers.superAdmin(), req); + + CmsSite saved = siteService.getByCode("theme3"); + Map<String, Object> config = com.yunhai.cms.utils.ExtJsonUtils.parse(saved.getConfigJson()); + assertThat(config).as("其余保留键不得被合并覆盖") + .containsEntry("logo", "/upload/cms/logo.png") + .containsEntry("contact", "0571-88888888"); + assertThat(config.get("theme_params")).isInstanceOf(Map.class); + assertThat(((Map<?, ?>) config.get("theme_params")).get("primary")) + .as("声明过的 key 应写入").isEqualTo("#ff0000"); + } + + @Test + @DisplayName("T12: themeParams 未声明 key 服务端丢弃,不落库") + void save_undeclaredThemeParamKeyDropped() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("theme4"); + req.setSchId(1); + req.setName("非法参数丢弃站"); + Map<String, String> params = new LinkedHashMap<>(); + params.put("zzhack", "zzvalue"); + req.setThemeParams(params); + + siteService.save(TestUsers.superAdmin(), req); + + CmsSite saved = siteService.getByCode("theme4"); + assertThat(saved.getConfigJson()).as("未声明 key 不得落库").doesNotContain("zzhack"); + Map<String, Object> config = com.yunhai.cms.utils.ExtJsonUtils.parse(saved.getConfigJson()); + assertThat(config).as("过滤后无有效参数 → 不写入 theme_params 键") + .doesNotContainKey("theme_params"); + } + + /** + * 验收问题 A-09:validateConfigJson 以「空 Map 且长度>2」判非法, + * 会把带空白的空对象 " {} "(合法 JSON 对象)误判为非法,而单字符标量(如 "5") + * 因长度不大于 2 反而放行。docs/09 §3.1 改为「解析成功且为对象」判定后启用本用例。 + */ + @Test + @DisplayName("A-09 修复:带空白空对象 \" { } \" 是合法 JSON 对象,放行") + void save_spacedEmptyObjectShouldPass() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("cfg3"); + req.setSchId(1); + req.setName("带空白空对象"); + req.setConfigJson(" { } "); + assertThat(siteService.save(TestUsers.superAdmin(), req)).isNotNull(); + } + + // ===== config_json 已知键校验(docs/09 §3.1,与前端 siteConfigForm.ts 同语义)===== + + @Test + @DisplayName("已知键合法值直通:domain 域名 / public_base_url URL / logo 路径 / wechat 预留对象无损保留") + void save_configKnownKeysValidPasses() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("cfg4"); + req.setSchId(1); + req.setName("已知键校验站"); + req.setConfigJson("{\"domain\":\"www.qizhong.edu.cn\"," + + "\"public_base_url\":\"https://www.qizhong.edu.cn\"," + + "\"logo\":\"/cms/upload/2026/06/logo.png\"," + + "\"favicon\":\"https://cdn.example.com/favicon.png\"," + + "\"wechat\":{\"appid\":\"wx123\"}}"); + siteService.save(TestUsers.superAdmin(), req); + CmsSite saved = siteService.getByCode("cfg4"); + Map<String, Object> config = com.yunhai.cms.utils.ExtJsonUtils.parse(saved.getConfigJson()); + assertThat(config.get("wechat")).as("预留对象键无损保留") + .isEqualTo(new LinkedHashMap<String, Object>() {{ put("appid", "wx123"); }}); + assertThat(config.get("favicon")).isEqualTo("https://cdn.example.com/favicon.png"); + } + + @Test + @DisplayName("domain 非域名格式被拒(带协议/带路径)") + void save_configInvalidDomainRejected() { + SiteSaveRequest req = baseConfigReq("cfg5"); + req.setConfigJson("{\"domain\":\"https://www.xxx.edu.cn\"}"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("域名"); + } + + @Test + @DisplayName("public_base_url 缺协议被拒(域名字符串不算完整 URL)") + void save_configInvalidPublicBaseUrlRejected() { + SiteSaveRequest req = baseConfigReq("cfg6"); + req.setConfigJson("{\"public_base_url\":\"www.xxx.edu.cn\"}"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("http"); + } + + @Test + @DisplayName("logo 非 / 开头且非 http(s) 被拒(XSS 协议拦截)") + void save_configInvalidLogoRejected() { + SiteSaveRequest req = baseConfigReq("cfg7"); + req.setConfigJson("{\"logo\":\"javascript:alert(1)\"}"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("logo"); + } + + @Test + @DisplayName("扩展键名不合法(连字符/数字开头)被拒;theme_params 系统键直通") + void save_configExtraKeyNameValidation() { + SiteSaveRequest req = baseConfigReq("cfg8"); + req.setConfigJson("{\"my-ext\":\"v\"}"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("键名不合法"); + + SiteSaveRequest digit = baseConfigReq("cfg9"); + digit.setConfigJson("{\"1bad\":\"v\"}"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), digit)) + .isInstanceOf(BizException.class) + .hasMessageContaining("键名不合法"); + + // theme_params 为系统托管键,键名规则之外直通 + SiteSaveRequest themeKey = baseConfigReq("cfg10"); + themeKey.setConfigJson("{\"theme_params\":{\"primary\":\"#ff0000\"}}"); + assertThat(siteService.save(TestUsers.superAdmin(), themeKey)).isNotNull(); + } + + private SiteSaveRequest baseConfigReq(String code) { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode(code); + req.setSchId(1); + req.setName("配置键校验站-" + code); + return req; + } + + @Test + @DisplayName("B-8:顶层站缺 schId 被拒(否则建成即校级角色不可管);院系子站留空继承不受影响") + void save_topLevelWithoutSchIdRejected() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("noschtop"); + req.setName("无校顶层站"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("学校ID"); + + // 院系子站留空 schId 合法(validateParent 继承主站),见 save_collegeSubSiteInheritsSchool + } + + @Test + @DisplayName("getByCode 未命中返回 null(门户快照兜底路径)") + void getByCode_missingReturnsNull() { + assertThat(siteService.getByCode("no-such-site")).isNull(); + } + + // ===== 院系子站层级(《院系子站方案》)===== + + @Test + @DisplayName("建院系子站:挂主站成功,缺省继承主站学校信息,列表回填 parentName") + void save_collegeSubSiteInheritsSchool() { + CmsSite main = persistSite("mainu", 9, "云海大学门户"); + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("jdx"); + req.setParentSiteId(main.getId()); + req.setName("机电学院"); + + SiteVo vo = siteService.save(TestUsers.superAdmin(), req); + assertThat(vo.getParentSiteId()).isEqualTo(main.getId()); + CmsSite saved = siteService.getByCode("jdx"); + assertThat(saved.getParentSiteId()).isEqualTo(main.getId()); + assertThat(saved.getSchId()).isEqualTo(9); + assertThat(saved.getSchName()).isEqualTo("学校-9"); + + List<SiteVo> sites = siteService.listFor(TestUsers.superAdmin()); + assertThat(sites).extracting(SiteVo::getCode).contains("jdx"); + SiteVo collegeVo = sites.stream().filter(s -> "jdx".equals(s.getCode())).findFirst().get(); + assertThat(collegeVo.getParentName()).isEqualTo("云海大学门户"); + } + + @Test + @DisplayName("建院系子站:上级不存在被拒(边界)") + void save_missingParentRejected() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("orphan"); + req.setParentSiteId(99999); + req.setName("孤儿院系站"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("上级站点不存在"); + } + + @Test + @DisplayName("建院系子站:院系子站下不允许再挂子站(层级固定两层)") + void save_collegeUnderCollegeRejected() { + CmsSite main = persistSite("mainu2", 9, "云海大学门户"); + persistSite("jdx2", 9, "机电学院", main.getId()); + Integer collegeId = siteService.getByCode("jdx2").getId(); + + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("deep"); + req.setParentSiteId(collegeId); + req.setName("二级子站"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不允许再挂子站"); + } + + @Test + @DisplayName("建院系子站:与主站跨校被拒") + void save_parentCrossSchoolRejected() { + CmsSite main = persistSite("mainu3", 9, "云海大学门户"); + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("other"); + req.setParentSiteId(main.getId()); + req.setSchId(8); + req.setName("他校院系站"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("同一学校"); + } + + @Test + @DisplayName("已挂子站的顶层站禁止改挂为子站(防院系列表锚点悬空)") + void save_topLevelWithChildrenCannotBecomeCollege() { + CmsSite main = persistSite("mainu4", 9, "云海大学门户"); + persistSite("jdx4", 9, "机电学院", main.getId()); + CmsSite other = persistSite("otheru", 10, "另一大学门户"); + + SiteSaveRequest req = new SiteSaveRequest(); + req.setId(main.getId()); + req.setCode("mainu4"); + req.setParentSiteId(other.getId()); + req.setName("云海大学门户"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不能改为院系子站"); + } + + @Test + @DisplayName("编辑院系子站:可改挂到另一顶层主站(同校)") + void save_collegeCanMoveToAnotherMain() { + CmsSite mainA = persistSite("maina", 9, "主站A"); + persistSite("mainb", 9, "主站B"); + persistSite("jdx5", 9, "机电学院", mainA.getId()); + Integer collegeId = siteService.getByCode("jdx5").getId(); + Integer mainBId = siteService.getByCode("mainb").getId(); + + SiteSaveRequest req = new SiteSaveRequest(); + req.setId(collegeId); + req.setCode("jdx5"); + req.setParentSiteId(mainBId); + req.setSchId(9); + req.setName("机电学院"); + SiteVo vo = siteService.save(TestUsers.superAdmin(), req); + assertThat(vo.getParentSiteId()).isEqualTo(mainBId); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/ViewCounterServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/ViewCounterServiceIT.java new file mode 100644 index 0000000..ef3823a --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/ViewCounterServiceIT.java @@ -0,0 +1,63 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 浏览量异步累加集成测试(方案 §5.2:详情渲染 fire-and-forget UPDATE,失败忽略)。 + */ +@DisplayName("ViewCounterService - 浏览量异步累加") +class ViewCounterServiceIT extends CmsIntegrationTestBase { + + @Autowired + private ViewCounterService viewCounterService; + + private Integer articleId; + + @BeforeEach + void seed() { + purgeTables(); + Integer siteId = persistSite("demo1", 1, "演示站点一").getId(); + Integer categoryId = persistCategory(siteId, 0, "新闻中心", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + articleId = persistArticle(siteId, categoryId, "被浏览文章", + ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60)).getId(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + private int viewCount() { + return articleRepository.findById(articleId) + .orElseThrow(IllegalStateException::new).getViewCount(); + } + + @Test + @DisplayName("increment 异步累加:多次调用最终全部生效(轮询等待落库)") + void increment_accumulatesAsynchronously() throws Exception { + assertThat(viewCount()).isZero(); + + for (int i = 0; i < 5; i++) { + viewCounterService.increment(articleId); + } + awaitTrue(5000, () -> viewCount() == 5); + assertThat(viewCount()).isEqualTo(5); + } + + @Test + @DisplayName("对不存在/null 的文章 increment 静默忽略(fire-and-forget 不抛异常)") + void increment_missingOrNullIsIgnored() { + viewCounterService.increment(null); + viewCounterService.increment(999999); + assertThat(viewCount()).isZero(); + } +} diff --git a/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java b/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java new file mode 100644 index 0000000..6285c14 --- /dev/null +++ b/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java @@ -0,0 +1,164 @@ +package com.yunhai.cms.support; + +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.entity.CmsBanner; +import com.yunhai.cms.entity.CmsCategory; +import com.yunhai.cms.entity.CmsLink; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.repository.CmsArticleRepository; +import com.yunhai.cms.repository.CmsAttachmentRepository; +import com.yunhai.cms.repository.CmsBannerRepository; +import com.yunhai.cms.repository.CmsCategoryRepository; +import com.yunhai.cms.repository.CmsLinkRepository; +import com.yunhai.cms.repository.CmsSiteRepository; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; + +import java.time.LocalDateTime; + +/** + * 集成测试基类:真实 Spring 上下文 + 本地 MySQL(bsch_cms_test) + Caffeine 缓存。 + * + * <p>所有子类共享同一上下文配置(本类注解即完整配置),Spring TestContext 只启动一次, + * schema-cms-test.sql 的 DROP/CREATE 也只执行一次;跨测试的库与缓存清洁由 + * {@link #clearCaches()} / {@link #purgeTables()} 负责: + * <ul> + * <li>@Transactional 用例:回滚即隔离,仅需清缓存(缓存是进程级全局的,不随事务回滚);</li> + * <li>非事务用例(事件 AFTER_COMMIT / MockMvc / 定时任务):@BeforeEach purge + @AfterEach purge。</li> + * </ul> + */ +@SpringBootTest(properties = "cms.schedule.auto-enabled=false") +@AutoConfigureMockMvc +public abstract class CmsIntegrationTestBase { + + @Autowired + protected CacheManager cacheManager; + @Autowired + protected CmsSiteRepository siteRepository; + @Autowired + protected CmsCategoryRepository categoryRepository; + @Autowired + protected CmsArticleRepository articleRepository; + @Autowired + protected CmsBannerRepository bannerRepository; + @Autowired + protected CmsLinkRepository linkRepository; + @Autowired + protected CmsAttachmentRepository attachmentRepository; + + @BeforeEach + void clearCachesBeforeEachTest() { + clearCaches(); + } + + /** 清空全部 CMS 缓存(cms:site / cms:article / cms:list),防止用例间缓存串扰 */ + protected void clearCaches() { + for (String name : cacheManager.getCacheNames()) { + cacheManager.getCache(name).clear(); + } + } + + /** 清空 6 张业务表(无外键约束,顺序无碍),非事务用例的隔离手段 */ + protected void purgeTables() { + attachmentRepository.deleteAllInBatch(); + articleRepository.deleteAllInBatch(); + categoryRepository.deleteAllInBatch(); + bannerRepository.deleteAllInBatch(); + linkRepository.deleteAllInBatch(); + siteRepository.deleteAllInBatch(); + } + + // ===== 落库种子(返回带自增 id 的实体)===== + + protected CmsSite persistSite(String code, Integer schId, String name) { + CmsSite site = new CmsSite(); + site.setCode(code); + site.setSchId(schId); + site.setSchName("学校-" + schId); + site.setName(name); + site.setTheme("default"); + site.setStatus(1); + return siteRepository.saveAndFlush(site); + } + + /** 带上级站点的落库(院系子站,《院系子站方案》):parentSiteId 传 null 视为顶层 0 */ + protected CmsSite persistSite(String code, Integer schId, String name, Integer parentSiteId) { + CmsSite site = persistSite(code, schId, name); + site.setParentSiteId(parentSiteId == null ? 0 : parentSiteId); + return siteRepository.saveAndFlush(site); + } + + protected CmsCategory persistCategory(Integer siteId, Integer parentId, String name, + CategoryTypeEnum type) { + CmsCategory c = new CmsCategory(); + c.setSiteId(siteId); + c.setParentId(parentId == null ? 0 : parentId); + c.setName(name); + c.setType(type.getCode()); + c.setStatus(1); + return categoryRepository.saveAndFlush(c); + } + + protected CmsArticle persistArticle(Integer siteId, Integer categoryId, String title, + ArticleStatusEnum status, LocalDateTime publishTime) { + CmsArticle a = new CmsArticle(); + a.setSiteId(siteId); + a.setCategoryId(categoryId); + a.setTitle(title); + a.setContent("<p>" + title + " 正文</p>"); + a.setSummary(title + " 摘要"); + a.setStatus(status.getCode()); + a.setPublishTime(publishTime); + return articleRepository.saveAndFlush(a); + } + + protected CmsBanner persistBanner(Integer siteId, String position, String title, + LocalDateTime start, LocalDateTime end) { + CmsBanner b = new CmsBanner(); + b.setSiteId(siteId); + b.setPosition(position); + b.setTitle(title); + b.setImageUrl("/upload/cms/banner.png"); + b.setLinkType(3); + b.setLinkValue("https://www.example.com"); + b.setStatus(1); + b.setStartTime(start); + b.setEndTime(end); + return bannerRepository.saveAndFlush(b); + } + + protected CmsLink persistLink(Integer siteId, String groupName, String name) { + CmsLink l = new CmsLink(); + l.setSiteId(siteId); + l.setGroupName(groupName); + l.setName(name); + l.setLinkUrl("https://www.example.com"); + l.setStatus(1); + return linkRepository.saveAndFlush(l); + } + + /** 简易轮询等待(无 awaitility 依赖):最长 waitMillis 内每 50ms 检查一次期望 */ + protected static void awaitTrue(long waitMillis, java.util.function.BooleanSupplier condition) + throws InterruptedException { + long deadline = System.currentTimeMillis() + waitMillis; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(50); + } + } + + protected static LocalDateTime nowPlusSeconds(long seconds) { + return LocalDateTime.now().plusSeconds(seconds); + } + + protected static LocalDateTime nowMinusSeconds(long seconds) { + return LocalDateTime.now().minusSeconds(seconds); + } +} diff --git a/src/test/java/com/yunhai/cms/support/TestUsers.java b/src/test/java/com/yunhai/cms/support/TestUsers.java new file mode 100644 index 0000000..ec66e21 --- /dev/null +++ b/src/test/java/com/yunhai/cms/support/TestUsers.java @@ -0,0 +1,55 @@ +package com.yunhai.cms.support; + +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.platform.utils.model.BoaUser; + +import java.util.Collections; + +/** + * 单测用户夹具:超管判定为 {@code BoaUser.type == 1}(平台约定,见 CmsAuthProperties)。 + * <ul> + * <li>superAdmin —— type=1,可管理所有站点(方案 §7.2 规则①)</li> + * <li>schoolUser —— 校级角色,仅可管理 currentRole.schId 匹配的站点(方案 §7.2 规则②)</li> + * <li>plainUser —— 无角色无 currentRole,不能管理任何站点</li> + * </ul> + */ +public final class TestUsers { + + private TestUsers() { + } + + public static BoaUser superAdmin() { + BoaUser user = new BoaUser(); + user.setId(1); + user.setAccount("admin"); + user.setName("平台管理员"); + user.setType(1); + return user; + } + + public static BoaUser schoolUser(Integer schId) { + return schoolUser(schId, "school_editor", "校级编辑"); + } + + public static BoaUser schoolUser(Integer schId, String account, String name) { + BoaUser user = new BoaUser(); + user.setId(2); + user.setAccount(account); + user.setName(name); + user.setRoleIds(Collections.singletonList(2)); + BoaRoleInfo role = new BoaRoleInfo(); + role.setRoleId(2); + role.setSchId(schId); + user.setCurrentRole(role); + return user; + } + + /** 无角色、无 currentRole:listFor 应返回空,canManage 恒 false */ + public static BoaUser plainUser() { + BoaUser user = new BoaUser(); + user.setId(3); + user.setAccount("nobody"); + user.setName("无角色用户"); + return user; + } +} diff --git a/src/test/java/com/yunhai/cms/theme/PreviewApiIT.java b/src/test/java/com/yunhai/cms/theme/PreviewApiIT.java new file mode 100644 index 0000000..c24184d --- /dev/null +++ b/src/test/java/com/yunhai/cms/theme/PreviewApiIT.java @@ -0,0 +1,194 @@ +package com.yunhai.cms.theme; + +import com.yunhai.cms.controller.api.ThemePreviewController; +import com.yunhai.cms.controller.portal.PortalPageRenderer; +import com.yunhai.cms.service.impl.PortalQueryService; +import com.yunhai.cms.service.impl.SiteService; +import com.yunhai.cms.service.impl.ThemeService; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 主题预览接口(方案 §8.4 P1 矩阵 T23-T27,§5.5)。 + * 控制器级 standalone MockMvc + 真实 Thymeleaf 视图解析器(不走 CAS 过滤器链,§8.6 分层)。 + * B-6 后 preview 需 BoaUser + 可管理校验:standalone 用参数解析器注入超管(负例直调控制器)。 + */ +@DisplayName("PreviewApi - /api/theme/preview 预览(方案 §5.5)") +class PreviewApiIT extends CmsIntegrationTestBase { + + @Autowired + private ThemeService themeService; + @Autowired + private PortalQueryService portalQueryService; + @Autowired + private PortalPageRenderer pageRenderer; + @Autowired + private SiteService siteService; + + private MockMvc standalone; + private ThemePreviewController controller; + + @BeforeEach + void seed() { + purgeTables(); + com.yunhai.cms.entity.CmsSite site = persistSite("demo", 1, "预览内容源门户"); + Integer categoryId = persistCategory(site.getId(), 0, "新闻中心", + com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + persistArticle(site.getId(), categoryId, "预览测试文章", + com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60)); + controller = new ThemePreviewController(themeService, portalQueryService, pageRenderer, siteService); + standalone = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return com.yunhai.platform.utils.model.BoaUser.class + .isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return TestUsers.superAdmin(); + } + }) + .setViewResolvers(thymeleafViewResolverForStandalone()) + .build(); + } + + /** standalone 环境专用视图解析器:真上下文的 resolver 已初始化,不能复用(Cannot reinitialize) */ + private org.thymeleaf.spring5.view.ThymeleafViewResolver thymeleafViewResolverForStandalone() { + org.thymeleaf.spring5.SpringTemplateEngine engine = new org.thymeleaf.spring5.SpringTemplateEngine(); + org.thymeleaf.templateresolver.ITemplateResolver templateResolver = + new org.thymeleaf.templateresolver.ClassLoaderTemplateResolver() { + { + setPrefix("/templates/"); + setSuffix(".html"); + setCharacterEncoding("UTF-8"); + } + }; + engine.setTemplateResolver(templateResolver); + // Boot 自动装配会注册 #temporals 方言,standalone 引擎需手动补齐 + engine.addDialect(new org.thymeleaf.extras.java8time.dialect.Java8TimeDialect()); + org.thymeleaf.spring5.view.ThymeleafViewResolver viewResolver = + new org.thymeleaf.spring5.view.ThymeleafViewResolver(); + viewResolver.setTemplateEngine(engine); + viewResolver.setCharacterEncoding("UTF-8"); + return viewResolver; + } + + @AfterEach + void clean() { + purgeTables(); + } + + private String body(MvcResult result) throws java.io.UnsupportedEncodingException { + return result.getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + @Test + @DisplayName("T23: 预览按指定主题渲染(含站点名与主题标记)") + void previewRendersSpecifiedTheme() throws Exception { + MvcResult result = standalone.perform(get("/api/theme/preview") + .param("siteCode", "demo").param("theme", "t-demo-red")) + .andExpect(status().isOk()).andReturn(); + String html = body(result); + assertThat(html).contains("预览内容源门户"); + assertThat(html).as("资产经主题代理即主题标记").contains("t-demo-red"); + } + + @Test + @DisplayName("T24: 预览注入 p_ 参数(p_primary=#b01a2e → --primary:#b01a2e)") + void previewInjectsParams() throws Exception { + MvcResult result = standalone.perform(get("/api/theme/preview") + .param("siteCode", "demo").param("theme", "t-demo-red") + .param("p_primary", "#b01a2e")) + .andExpect(status().isOk()).andReturn(); + assertThat(body(result)).contains("--primary:#b01a2e"); + } + + @Test + @DisplayName("T25: 预览资产指向管理端代理(R1 串包修复的核心断言)") + void previewAssetsPointToAdminProxy() throws Exception { + MvcResult result = standalone.perform(get("/api/theme/preview") + .param("siteCode", "demo").param("theme", "t-demo-red")) + .andExpect(status().isOk()).andReturn(); + assertThat(body(result)).contains("/api/theme/t-demo-red/asset/css/main.css"); + } + + @Test + @DisplayName("T26: 预览响应 no-store(不进缓存/CDN)") + void previewNoStore() throws Exception { + standalone.perform(get("/api/theme/preview") + .param("siteCode", "demo").param("theme", "default")) + .andExpect(header().string("Cache-Control", "no-store")); + } + + @Test + @DisplayName("T27: 未注册主题 PARAM_ERROR(明确告知,不静默回退)") + void previewUnregisteredThemeRejected() { + assertThatThrownBy(() -> themeService.requireRegistered("no-such")) + .isInstanceOf(BizException.class) + .hasMessageContaining("皮肤包未注册"); + } + + // ===== B-6 负例(page 白名单 + 可管理校验)===== + + @Test + @DisplayName("B-6①:page 非 home 白名单值报 PARAM_ERROR,不静默按 home 渲染(T-5)") + void previewRejectsNonHomePage() { + assertThatThrownBy(() -> controller.preview("demo", "t-demo-red", "list", + TestUsers.superAdmin(), + new org.springframework.mock.web.MockHttpServletRequest(), + new org.springframework.mock.web.MockHttpServletResponse(), + new org.springframework.validation.support.BindingAwareModelMap())) + .isInstanceOf(BizException.class) + .hasMessageContaining("不支持的预览页面"); + } + + @Test + @DisplayName("B-6②:不可管理内容源站点的用户预览被拒 FORBIDDEN(T-5)") + void previewRejectsUnmanageableUser() { + // 内容源站点 schId=1,学校2 用户不可管理 + assertThatThrownBy(() -> controller.preview("demo", "t-demo-red", "home", + TestUsers.schoolUser(2), + new org.springframework.mock.web.MockHttpServletRequest(), + new org.springframework.mock.web.MockHttpServletResponse(), + new org.springframework.validation.support.BindingAwareModelMap())) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("B-6②:未登录(无 BoaUser)预览被拒(standalone 无解析器时参数为 null)") + void previewRejectsMissingUser() { + assertThatThrownBy(() -> controller.preview("demo", "t-demo-red", "home", + null, + new org.springframework.mock.web.MockHttpServletRequest(), + new org.springframework.mock.web.MockHttpServletResponse(), + new org.springframework.validation.support.BindingAwareModelMap())) + .isInstanceOf(BizException.class) + .hasMessageContaining("未登录"); + } +} diff --git a/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java b/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java new file mode 100644 index 0000000..984c871 --- /dev/null +++ b/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java @@ -0,0 +1,112 @@ +package com.yunhai.cms.theme; + +import com.yunhai.cms.controller.api.ThemeController; +import com.yunhai.cms.service.impl.ThemeService; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 主题管理接口契约(方案 §8.3 E 组 T18-T21,§5.2)。 + * 测试分层(§8.6):T18 全上下文 MockMvc(匿名 CAS 302 契约,现有唯一先例模式); + * T19 服务级直调;T20/T21 standalone MockMvc(不走 CAS 过滤器链的控制器薄契约)。 + */ +@DisplayName("ThemeApi - /api/theme 接口契约(方案 §5.2)") +class ThemeApiIT extends CmsIntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ThemeService themeService; + @Autowired + private ThemeRegistry themeRegistry; + + private MockMvc standalone; + + @BeforeEach + void setUp() { + purgeTables(); + standalone = MockMvcBuilders + .standaloneSetup(new ThemeController(themeService, themeRegistry)).build(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + @Test + @DisplayName("T18: /api/theme/list 匿名 302 至 SSO(CAS 契约,同 /api/site 先例)") + void themeList_anonymousRedirectsToSso() throws Exception { + mockMvc.perform(get("/api/theme/list")) + .andExpect(status().is3xxRedirection()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .contains("sso.")); + } + + @Test + @DisplayName("T19: 主题列表返回 schema(含 default:name/title/params/bannerSize/screenshotUrl)") + void themeList_containsDefaultWithSchema() { + List<com.yunhai.cms.model.vo.ThemeVo> list = themeService.list(); + assertThat(list).as("至少包含 default(Registry 已注册)") + .extracting(vo -> vo.getName()).contains("default"); + + com.yunhai.cms.model.vo.ThemeVo def = list.stream() + .filter(v -> "default".equals(v.getName())) + .findFirst().orElseThrow(IllegalStateException::new); + assertThat(def.getTitle()).isNotBlank(); + assertThat(def.getBannerSize()).isNotBlank(); + assertThat(def.getScreenshotUrl()).isEqualTo("/api/theme/default/asset/screenshot.png"); + assertThat(def.getParams()).isNotEmpty(); + com.yunhai.cms.model.vo.ThemeVo.ThemeParamVo primary = def.getParams().stream() + .filter(p -> "primary".equals(p.getKey())) + .findFirst().orElseThrow(IllegalStateException::new); + assertThat(primary.getType()).isEqualTo("color"); + assertThat(primary.getCssVar()).isEqualTo("--primary"); + assertThat(primary.getDefaultValue()).isNotBlank(); + } + + @Test + @DisplayName("T20: 资产代理防穿越(..%2f 拒绝 400/404)") + void assetTraversalRejected() throws Exception { + standalone.perform(get("/api/theme/t-demo-red/asset/..%2F..%2Fapplication.yml")) + .andExpect(result -> assertThat(result.getResponse().getStatus()).isIn(400, 404)); + } + + @Test + @DisplayName("T21: 未注册主题资产 404(不落目录拼接)") + void unregisteredThemeAsset404() throws Exception { + standalone.perform(get("/api/theme/no-such-theme/asset/screenshot.png")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("T21 补充: 夹具主题 screenshot 经代理可取且带缓存头") + void fixtureScreenshotServed() throws Exception { + standalone.perform(get("/api/theme/t-demo-red/asset/screenshot.png")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "max-age=3600, public")); + } + + @Test + @DisplayName("P2-T32: 三新包 screenshot 经资产代理可取(registry 校验存在 + 接口冒烟)") + void newPackageScreenshotsServed() throws Exception { + for (String name : new String[] {"classic-red", "blue-white", "warm-kinder"}) { + standalone.perform(get("/api/theme/{name}/asset/screenshot.png", name)) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "max-age=3600, public")); + } + } +} diff --git a/src/test/java/com/yunhai/cms/theme/ThemeRegistryTest.java b/src/test/java/com/yunhai/cms/theme/ThemeRegistryTest.java new file mode 100644 index 0000000..ef13992 --- /dev/null +++ b/src/test/java/com/yunhai/cms/theme/ThemeRegistryTest.java @@ -0,0 +1,155 @@ +package com.yunhai.cms.theme; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * 主题注册表纯单元测试(方案 §8.3 A 组 T1-T8):直接 new,不启 Spring。 + * 夹具主题在 src/test/resources/templates/themes/(t- 前缀,§8.7), + * 断言一律用"包含/不包含",禁止精确数量断言(夹具被全量 IT 拾取)。 + */ +@DisplayName("ThemeRegistry - 主题包注册与校验(方案 §4.1/§5.1)") +class ThemeRegistryTest { + + private ThemeRegistry registry; + + @BeforeEach + void setUp() { + registry = new ThemeRegistry(); + registry.load(); + } + + @Test + @DisplayName("T1: default 包注册成功且声明 primary 参数(color/cssVar)") + void defaultPackageRegisteredWithPrimaryParam() { + ThemeInfo info = registry.get(ThemeRegistry.DEFAULT_THEME); + assertThat(info).as("default 包必须注册(缺清单或非法则本测试红,§4.1 theme.json 必备)") + .isNotNull(); + assertThat(info.getName()).isEqualTo("default"); + assertThat(info.getTitle()).isNotBlank(); + assertThat(info.getSpecVersion()).isEqualTo(1); + ThemeParam primary = info.param("primary"); + assertThat(primary).isNotNull(); + assertThat(primary.getType()).isEqualTo(ThemeParam.TYPE_COLOR); + assertThat(primary.getCssVar()).isEqualTo("--primary"); + assertThat(primary.getDefaultValue()).isNotBlank(); + } + + @Test + @DisplayName("T2: 坏 JSON 的包被剔除(t-broken-json)") + void brokenJsonPackageRejected() { + assertThat(registry.contains("t-broken-json")).isFalse(); + assertThat(registry.contains("t-demo-red")) + .as("合法夹具包必须注册成功(防止'全部剔除'假绿)").isTrue(); + } + + @Test + @DisplayName("T3: 缺必备模板的包被剔除(t-missing-list:有清单无 list.html)") + void missingTemplatePackageRejected() { + assertThat(registry.contains("t-missing-list")).isFalse(); + assertThat(registry.contains("t-demo-red")).isTrue(); + } + + @Test + @DisplayName("T4: name 与目录名不一致的包被剔除(t-mismatch)") + void nameMismatchPackageRejected() { + assertThat(registry.contains("t-mismatch")).isFalse(); + assertThat(registry.contains("t-mismatch-x")).isFalse(); + assertThat(registry.contains("t-demo-red")).isTrue(); + } + + @Test + @DisplayName("T5: param key 含非法字符的包被剔除(t-bad-key)") + void badParamKeyPackageRejected() { + assertThat(registry.contains("t-bad-key")).isFalse(); + assertThat(registry.contains("t-demo-red")).isTrue(); + } + + @Test + @DisplayName("T6: cssVar 出现在非 color 类型参数上被剔除(t-bad-cssvar)") + void cssVarOnNonColorParamRejected() { + assertThat(registry.contains("t-bad-cssvar")).isFalse(); + assertThat(registry.contains("t-demo-red")).isTrue(); + } + + @Test + @DisplayName("T7: screenshot 声明的资源缺失被剔除(t-bad-shot)") + void missingScreenshotPackageRejected() { + assertThat(registry.contains("t-bad-shot")).isFalse(); + assertThat(registry.contains("t-demo-red")).isTrue(); + } + + @Test + @DisplayName("T8: 列表至少包含 default(护栏用例)") + void listContainsDefault() { + assertThat(registry.list()) + .extracting(ThemeInfo::getName) + .contains(ThemeRegistry.DEFAULT_THEME); + } + + @Test + @DisplayName("P2-T33: 三新包参数默认值互异(防拷贝忘改的低成本哨兵)") + void newPackagesPrimaryDefaultsDiffer() { + String defaultPrimary = registry.get(ThemeRegistry.DEFAULT_THEME).param("primary").getDefaultValue(); + for (String name : new String[] {"classic-red", "blue-white", "warm-kinder"}) { + ThemeParam primary = registry.get(name).param("primary"); + assertThat(primary).as("%s 应声明 primary 参数", name).isNotNull(); + assertThat(primary.getDefaultValue()) + .as("%s 的 primary 默认值不得与 default 相同(拷贝忘改哨兵)", name) + .isNotEqualTo(defaultPrimary); + } + } + + @Test + @DisplayName("default 包缺失时 fail-fast(方案 §5.1,启动失败防线)") + void defaultMissingFailsFast() { + ThemeRegistry empty = new ThemeRegistry() { + @Override + public void load() { + // 模拟扫描不到任何包:load 为空即注册表为空 + } + }; + assertThatCode(empty::verifyDefaultPresent) + .as("default 缺失必须抛异常阻止启动(fail-fast,§5.1)") + .isInstanceOf(IllegalStateException.class); + } + + // ===== B-7:image 类型参数路径校验与应用真实路径一致(T-6)===== + + @Test + @DisplayName("B-7: image 参数仅 /cms/upload/** 合法(含 context-path),旧 /upload/** 不再放行") + void imageParamValidatesAgainstContextUploadPath() { + ThemeParam image = new ThemeParam(); + image.setKey("heroImage"); + image.setType(ThemeParam.TYPE_IMAGE); + assertThat(image.isValidValue("/cms/upload/cms/demo/202609/a.jpg")).isTrue(); + assertThat(image.isValidValue("/upload/cms/demo/a.jpg")) + .as("旧约定缺 /cms 前缀,context-path=/cms 部署下渲染必 404").isFalse(); + assertThat(image.isValidValue("javascript:alert(1)")).isFalse(); + assertThat(image.isValidValue("https://evil.example/x.png")).isFalse(); + assertThat(image.isValidValue(null)).isFalse(); + assertThat(image.isValidValue("")).isFalse(); + } + + @Test + @DisplayName("B-7/T-6: image 参数端到端——夹具声明 → 注册加载 → filterValidValues 按 /cms/upload/ 过滤") + void imageParamFixtureEndToEnd() { + ThemeInfo info = registry.get("t-demo-red"); + ThemeParam hero = info.param("heroImage"); + assertThat(hero).as("夹具包应声明 image 类型参数").isNotNull(); + assertThat(hero.getType()).isEqualTo(ThemeParam.TYPE_IMAGE); + assertThat(hero.getDefaultValue()).isEqualTo("/cms/upload/cms/t-demo-red/hero.png"); + + assertThat(info.filterValidValues(java.util.Collections.singletonMap( + "heroImage", "/cms/upload/cms/x/202609/b.png"))) + .containsKey("heroImage"); + assertThat(info.filterValidValues(java.util.Collections.singletonMap( + "heroImage", "/upload/cms/x/b.png"))) + .as("旧 /upload/** 值应被过滤丢弃,不落库") + .doesNotContainKey("heroImage"); + } +} diff --git a/src/test/java/com/yunhai/cms/theme/ThemeVarsCssTest.java b/src/test/java/com/yunhai/cms/theme/ThemeVarsCssTest.java new file mode 100644 index 0000000..59be1f7 --- /dev/null +++ b/src/test/java/com/yunhai/cms/theme/ThemeVarsCssTest.java @@ -0,0 +1,70 @@ +package com.yunhai.cms.theme; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * themeVarsCss 构建纯函数测试(方案 §8.3 C 组 T16)。 + */ +@DisplayName("ThemeVarsCss - CSS 变量覆盖串构建(方案 §5.3)") +class ThemeVarsCssTest { + + private ThemeInfo schema() { + ThemeInfo info = new ThemeInfo(); + info.setName("t-demo-red"); + info.getParams().add(param("primary", "--primary", ThemeParam.TYPE_COLOR, "#b01a2e")); + info.getParams().add(param("bg", "--bg", ThemeParam.TYPE_COLOR, "#f7f5f2")); + info.getParams().add(param("headerStyle", null, ThemeParam.TYPE_SELECT, "banner")); + return info; + } + + private ThemeParam param(String key, String cssVar, String type, String defaultValue) { + ThemeParam p = new ThemeParam(); + p.setKey(key); + p.setCssVar(cssVar); + p.setType(type); + p.setDefaultValue(defaultValue); + return p; + } + + @Test + @DisplayName("T16: 给定 schema+params 构建期望 :root 串;非法值剔除该变量") + void buildProducesRootBlockAndDropsInvalidValues() { + ThemeInfo schema = schema(); + Map<String, String> params = new LinkedHashMap<>(); + params.put("primary", "#b01a2e"); + params.put("bg", "#f7f5f2"); + params.put("headerStyle", "plain"); // 无 cssVar,不进 CSS + + String css = ThemeVarsCss.build(schema, params); + assertThat(css).isEqualTo(":root{--primary:#b01a2e;--bg:#f7f5f2;}"); + } + + @Test + @DisplayName("T16: 非法色值剔除该变量,其余合法声明保留") + void buildDropsInvalidColorButKeepsValidOnes() { + ThemeInfo schema = schema(); + Map<String, String> params = new LinkedHashMap<>(); + params.put("primary", "red;}"); // 非法 + params.put("bg", "#f7f5f2"); + + String css = ThemeVarsCss.build(schema, params); + assertThat(css).isEqualTo(":root{--bg:#f7f5f2;}"); + assertThat(css).doesNotContain("red;}"); + } + + @Test + @DisplayName("T16: 空参数/全部非法时返回空串(模板 th:if 不输出 style)") + void buildReturnsEmptyWhenNoValidDeclarations() { + assertThat(ThemeVarsCss.build(schema(), new LinkedHashMap<>())).isEmpty(); + + Map<String, String> invalid = new LinkedHashMap<>(); + invalid.put("primary", "javascript:alert(1)"); + assertThat(ThemeVarsCss.build(schema(), invalid)).isEmpty(); + } +} diff --git a/src/test/java/com/yunhai/cms/utils/ExtJsonUtilsTest.java b/src/test/java/com/yunhai/cms/utils/ExtJsonUtilsTest.java new file mode 100644 index 0000000..464066c --- /dev/null +++ b/src/test/java/com/yunhai/cms/utils/ExtJsonUtilsTest.java @@ -0,0 +1,53 @@ +package com.yunhai.cms.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * JSON 扩展列工具单测(方案 §5.2:ext_json/config_json 解析失败不影响主流程)。 + */ +@DisplayName("ExtJsonUtils - 扩展 JSON 列解析") +class ExtJsonUtilsTest { + + @Test + @DisplayName("合法 JSON 对象解析为 Map") + void parse_validObject() { + Map<String, Object> map = ExtJsonUtils.parse("{\"domain\":\"www.xxx.edu.cn\",\"n\":3}"); + assertThat(map) + .containsEntry("domain", "www.xxx.edu.cn") + .containsEntry("n", 3); + } + + @Test + @DisplayName("null/空串/空白 返回空 Map(不抛异常)") + void parse_blankReturnsEmpty() { + assertThat(ExtJsonUtils.parse(null)).isEmpty(); + assertThat(ExtJsonUtils.parse("")).isEmpty(); + assertThat(ExtJsonUtils.parse(" ")).isEmpty(); + } + + @Test + @DisplayName("非法 JSON / 非对象 JSON(数组、字符串、数字)一律降级为空 Map") + void parse_invalidOrNonObjectReturnsEmpty() { + assertThat(ExtJsonUtils.parse("not-json{")).isEmpty(); + assertThat(ExtJsonUtils.parse("[1,2,3]")).isEmpty(); + assertThat(ExtJsonUtils.parse("\"text\"")).isEmpty(); + assertThat(ExtJsonUtils.parse("123")).isEmpty(); + } + + @Test + @DisplayName("getString:命中返回字符串形态,缺失/null 安全") + void getString_variants() { + Map<String, Object> map = ExtJsonUtils.parse( + "{\"domain\":\"d\",\"num\":42,\"nil\":null}"); + assertThat(ExtJsonUtils.getString(map, "domain")).isEqualTo("d"); + assertThat(ExtJsonUtils.getString(map, "num")).isEqualTo("42"); + assertThat(ExtJsonUtils.getString(map, "nil")).isNull(); + assertThat(ExtJsonUtils.getString(map, "missing")).isNull(); + assertThat(ExtJsonUtils.getString(null, "domain")).isNull(); + } +} diff --git a/src/test/java/com/yunhai/cms/utils/HtmlSanitizerTest.java b/src/test/java/com/yunhai/cms/utils/HtmlSanitizerTest.java new file mode 100644 index 0000000..0823fbd --- /dev/null +++ b/src/test/java/com/yunhai/cms/utils/HtmlSanitizerTest.java @@ -0,0 +1,190 @@ +package com.yunhai.cms.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 富文本白名单清洗单测(方案 §8.5 内容安全)。 + * 覆盖:正常富文本保留、XSS 载荷剥除、style 过滤、纯文本提取、空值边界。 + */ +@DisplayName("HtmlSanitizer - jsoup 白名单清洗") +class HtmlSanitizerTest { + + // ===== 正常流程 ===== + + @Test + @DisplayName("常规富文本(段落/标题/加粗/图片/链接)清洗后保留") + void clean_keepsCommonRichText() { + String html = "<h2>标题</h2><p>段落<strong>加粗</strong><em>斜体</em></p>" + + "<p><img src=\"https://cdn.example.com/a.png\" alt=\"图\"></p>" + + "<p><a href=\"https://www.example.com/page\">链接</a></p>" + + "<ul><li>列表项</li></ul>"; + String cleaned = HtmlSanitizer.clean(html); + assertThat(cleaned).contains("<h2>").contains("<strong>").contains("<em>") + .contains("<img").contains("src=\"https://cdn.example.com/a.png\"") + .contains("<a").contains("href=\"https://www.example.com/page\"") + .contains("<li>"); + } + + @Test + @DisplayName("wangEditor 图片段落 figure/figcaption 与 span 保留(编辑器产物结构)") + void clean_keepsFigureAndSpan() { + String html = "<figure><img src=\"https://cdn.example.com/p.png\"><figcaption>配图</figcaption></figure>" + + "<p><span style=\"color: red\">红字</span></p>"; + String cleaned = HtmlSanitizer.clean(html); + assertThat(cleaned).contains("<figure>").contains("<figcaption>").contains("<span"); + } + + @Test + @DisplayName("排版类内联 style(text-align/color 等)保留") + void clean_keepsSafeInlineStyle() { + String cleaned = HtmlSanitizer.clean("<p style=\"text-align: center; color: #333\">居中</p>"); + assertThat(cleaned).contains("text-align").contains("color"); + } + + // ===== XSS 异常分支 ===== + + @Test + @DisplayName("script 标签整体剥除(含内容)") + void clean_stripsScriptTag() { + String cleaned = HtmlSanitizer.clean("<p>正文</p><script>alert('x')</script>"); + assertThat(cleaned).doesNotContain("script").doesNotContain("alert"); + } + + @Test + @DisplayName("iframe 剥除") + void clean_stripsIframe() { + String cleaned = HtmlSanitizer.clean("<p>正文</p><iframe src=\"https://evil.example.com\"></iframe>"); + assertThat(cleaned).doesNotContain("iframe"); + } + + @Test + @DisplayName("on* 事件属性剥除(onclick/onerror)") + void clean_stripsEventHandlers() { + String cleaned = HtmlSanitizer.clean( + "<p onclick=\"evil()\">点击</p><img src=\"https://a.example.com/x.png\" onerror=\"evil()\">"); + assertThat(cleaned).doesNotContain("onclick").doesNotContain("onerror").doesNotContain("evil"); + } + + @Test + @DisplayName("a 的 javascript: 协议 href 剥除(保留标签本身)") + void clean_stripsJavascriptHref() { + String cleaned = HtmlSanitizer.clean("<p><a href=\"javascript:evil()\">点我</a></p>"); + assertThat(cleaned).doesNotContain("javascript:").doesNotContain("evil"); + assertThat(cleaned).contains("点我"); + } + + @Test + @DisplayName("style 含 url(...) 的元素移除 style 属性(防 CSS 外链/expression)") + void clean_stripsStyleWithUrlFunction() { + String cleaned = HtmlSanitizer.clean( + "<div style=\"background-image: url('https://evil.example.com/x.gif')\">区块</div>"); + assertThat(cleaned).doesNotContain("url(").doesNotContain("background-image"); + assertThat(cleaned).contains("区块"); + } + + @Test + @DisplayName("style 含 expression() 的元素移除 style 属性") + void clean_stripsStyleWithExpression() { + String cleaned = HtmlSanitizer.clean("<div style=\"width: expression(evail())\">区块</div>"); + assertThat(cleaned).doesNotContain("expression"); + } + + @Test + @DisplayName("svg 内嵌脚本无法存活(svg 不在白名单)") + void clean_stripsSvgPayload() { + String cleaned = HtmlSanitizer.clean( + "<svg onload=\"alert(1)\"><circle r=\"1\"></circle></svg><p>正文</p>"); + assertThat(cleaned).doesNotContain("onload").doesNotContain("<svg").contains("正文"); + } + + /** + * 验收问题 A-07:SAFE_STYLE 白名单未按设计生效——不匹配白名单但不含 url()/expression + * 的 style(如 position:fixed)目前会被保留,与类注释“其余 style 值丢弃”不符。 + * 修复 stripUnsafeStyle 逻辑后启用本用例。 + */ + @Test + @DisplayName("A-07:不匹配排版白名单的 style 属性整体丢弃(position:fixed 等)") + void clean_shouldDropNonWhitelistedStyleProperties() { + String cleaned = HtmlSanitizer.clean("<div style=\"position: fixed; top: 0\">悬浮</div>"); + assertThat(cleaned).doesNotContain("position"); + // 白名单内的 style 不受影响 + assertThat(HtmlSanitizer.clean("<p style=\"max-width: 100%\">图</p>")).contains("max-width"); + } + + // ===== 相对链接保留(day3 验收 B-9:编辑器贴图相对 src 被剥导致保存后断图) ===== + + @Test + @DisplayName("B-9:相对路径 img src 保留(上传地址约定相对入库,B-1)") + void clean_keepsRelativeImgSrc() { + String html = "<p>正文</p><p><img src=\"/cms/upload/cms/demo/202609/abc123.png\" alt=\"配图\" style=\"max-width:100%\"></p>"; + String cleaned = HtmlSanitizer.clean(html); + assertThat(cleaned).contains("src=\"/cms/upload/cms/demo/202609/abc123.png\"") + .contains("alt=\"配图\"").contains("max-width"); + } + + @Test + @DisplayName("B-9:相对路径 a href 保留") + void clean_keepsRelativeHref() { + String cleaned = HtmlSanitizer.clean("<p><a href=\"/cms/portal/demo/a/1\">站内文章</a></p>"); + assertThat(cleaned).contains("href=\"/cms/portal/demo/a/1\"").contains("站内文章"); + } + + @Test + @DisplayName("B-9:file:// 等未授权协议的绝对 src 仍剥除(保留相对放行不等于协议失控)") + void clean_stripsDisallowedAbsoluteProtocol() { + String cleaned = HtmlSanitizer.clean("<p><img src=\"file:///etc/passwd\" alt=\"x\"></p>"); + assertThat(cleaned).doesNotContain("file:").doesNotContain("passwd"); + } + + // ===== 纯文本提取 ===== + + @Test + @DisplayName("toText 去标签并折叠空白") + void toText_stripsTagsAndCollapsesWhitespace() { + String text = HtmlSanitizer.toText("<p>第一段</p>\n\n<p>第二段 继续</p>", 100); + assertThat(text).isEqualTo("第一段 第二段 继续"); + } + + @Test + @DisplayName("toText 按 maxLength 截断") + void toText_truncatesToMaxLength() { + String text = HtmlSanitizer.toText("<p>" + repeat('字', 200) + "</p>", 120); + assertThat(text).hasSize(120); + } + + @Test + @DisplayName("toText maxLength=0 不截断") + void toText_zeroMaxLengthNoTruncation() { + String text = HtmlSanitizer.toText("<p>完整内容</p>", 0); + assertThat(text).isEqualTo("完整内容"); + } + + // ===== 空值边界 ===== + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + @DisplayName("clean/toText 对 null/空串原样返回(不抛异常)") + void blankInput_returnsAsIs(String input) { + if (input == null || input.isEmpty()) { + assertThat(HtmlSanitizer.clean(input)).isEqualTo(input); + } else { + assertThat(HtmlSanitizer.clean(input)).isEmpty(); + } + assertThat(HtmlSanitizer.toText(input, 50)).isEmpty(); + } + + private static String repeat(char c, int count) { + StringBuilder sb = new StringBuilder(count); + for (int i = 0; i < count; i++) { + sb.append(c); + } + return sb.toString(); + } +} diff --git a/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java b/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java new file mode 100644 index 0000000..0c3e9c3 --- /dev/null +++ b/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java @@ -0,0 +1,185 @@ +package com.yunhai.cms.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 门户 URL 生成器单测(方案 §8.6 前置约定2:所有链接经 PortalUrlHelper 生成)。 + * 覆盖:请求派生形态、public_base_url 学校域形态、各 URL 段拼接、编码与边界; + * 资产 URL 主题段(B-3)、上传地址补基准与正文重写(B-1)。 + */ +@DisplayName("PortalUrlHelper - 门户 URL 生成") +class PortalUrlHelperTest { + + private static MockHttpServletRequest request(String scheme, String server, int port, + String contextPath) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme(scheme); + request.setServerName(server); + request.setServerPort(port); + request.setContextPath(contextPath); + return request; + } + + // ===== 请求派生形态(public_base_url 未配置,预览/平台内形态)===== + + @Test + @DisplayName("从请求派生基地址:非默认端口 + contextPath 均保留") + void fromRequest_keepsNonDefaultPortAndContext() { + PortalUrlHelper url = PortalUrlHelper.fromRequest( + request("http", "localhost", 8072, "/cms"), "demo", "default"); + assertThat(url.getBaseUrl()).isEqualTo("http://localhost:8072/cms/portal/demo"); + assertThat(url.home()).isEqualTo("http://localhost:8072/cms/portal/demo/"); + assertThat(url.category(5)).isEqualTo("http://localhost:8072/cms/portal/demo/c/5"); + assertThat(url.article(9)).isEqualTo("http://localhost:8072/cms/portal/demo/a/9"); + } + + @Test + @DisplayName("http:80 / https:443 默认端口省略") + void fromRequest_omitsDefaultPorts() { + assertThat(PortalUrlHelper.fromRequest(request("http", "a.com", 80, ""), "s1", "default").home()) + .isEqualTo("http://a.com/portal/s1/"); + assertThat(PortalUrlHelper.fromRequest(request("https", "a.com", 443, ""), "s1", "default").home()) + .isEqualTo("https://a.com/portal/s1/"); + } + + @Test + @DisplayName("search:关键词 URL 编码(UTF-8),page=1 不带页码、page>1 追加") + void search_encodesKeywordAndAppendsPageOnlyWhenBeyondFirst() throws Exception { + PortalUrlHelper url = new PortalUrlHelper("https://base.example.com/cms/portal/demo", "demo", "default"); + assertThat(url.search("开学 典礼", 1)) + .isEqualTo("https://base.example.com/cms/portal/demo/search?q=" + + java.net.URLEncoder.encode("开学 典礼", "UTF-8")); + assertThat(url.search("x", 3)) + .isEqualTo("https://base.example.com/cms/portal/demo/search?q=x&page=3"); + assertThat(url.search(null, null)) + .isEqualTo("https://base.example.com/cms/portal/demo/search?q="); + } + + // ===== 学校独立域名形态(config_json.public_base_url)===== + + @Test + @DisplayName("public_base_url 形态:站点路径段被剥离,直接以学校域为基准") + void publicBaseUrl_formUsesSchoolDomainAsBase() { + PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn/", "demo", "classic-red"); + assertThat(url.home()).isEqualTo("https://www.xxx.edu.cn/"); + assertThat(url.category(3)).isEqualTo("https://www.xxx.edu.cn/c/3"); + assertThat(url.article(7)).isEqualTo("https://www.xxx.edu.cn/a/7"); + assertThat(url.getSiteCode()).isEqualTo("demo"); + } + + @Test + @DisplayName("asset 入参前导斜杠兼容,重复尾斜杠的 base 归一") + void assetAndBaseTrimming() { + PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn///", "demo", "default"); + assertThat(url.getBaseUrl()).isEqualTo("https://www.xxx.edu.cn"); + assertThat(url.asset("/css/a.css")).isEqualTo("https://www.xxx.edu.cn/assets/default/css/a.css"); + assertThat(url.asset("css/a.css")).isEqualTo("https://www.xxx.edu.cn/assets/default/css/a.css"); + } + + @Test + @DisplayName("blank base 容错:不因空白 base 抛异常,路径仍以 / 起头") + void blankBaseTolerated() { + PortalUrlHelper url = new PortalUrlHelper(null, "demo", "default"); + assertThat(url.home()).isEqualTo("/"); + assertThat(url.article(1)).isEqualTo("/a/1"); + } + + // ===== 资产 URL 主题段(B-3:换肤即换 URL,浏览器/CDN 缓存天然失效)===== + + @Test + @DisplayName("asset 含主题段:theme=default 与 classic-red 生成不同 URL") + void asset_urlCarriesThemeSegment() { + PortalUrlHelper url = new PortalUrlHelper("http://localhost:8072/cms/portal/demo", "demo", "default"); + assertThat(url.asset("css/main.css")) + .isEqualTo("http://localhost:8072/cms/portal/demo/assets/default/css/main.css"); + + PortalUrlHelper switched = new PortalUrlHelper("http://localhost:8072/cms/portal/demo", "demo", "classic-red"); + assertThat(switched.asset("css/main.css")) + .isEqualTo("http://localhost:8072/cms/portal/demo/assets/classic-red/css/main.css"); + } + + @Test + @DisplayName("asset 主题段空值回退 default(防御:theme 字段异常不致 404)") + void asset_blankThemeFallsBackToDefault() { + PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn", "demo", null); + assertThat(url.asset("css/main.css")).isEqualTo("https://www.xxx.edu.cn/assets/default/css/main.css"); + } + + // ===== 上传地址补基准(B-1:webUrl 只存相对路径,渲染期补全)===== + + @Test + @DisplayName("upload:相对路径按基准 origin 补全(base 域形态 / 学校域形态)") + void upload_relativePathPrefixedWithOrigin() { + PortalUrlHelper base = new PortalUrlHelper("http://localhost:8072/cms/portal/demo", "demo", "default"); + assertThat(base.upload("/cms/upload/cms/demo/202609/abc.jpg")) + .isEqualTo("http://localhost:8072/cms/upload/cms/demo/202609/abc.jpg"); + + PortalUrlHelper school = new PortalUrlHelper("https://www.xxx.edu.cn", "demo", "default"); + assertThat(school.upload("/cms/upload/cms/demo/202609/abc.jpg")) + .isEqualTo("https://www.xxx.edu.cn/cms/upload/cms/demo/202609/abc.jpg"); + } + + @Test + @DisplayName("upload:已是完整 http(s) 地址原样返回(外链图不误改);空值原样返回") + void upload_absoluteAndBlankUntouched() { + PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn", "demo", "default"); + assertThat(url.upload("https://cdn.example.com/img.png")).isEqualTo("https://cdn.example.com/img.png"); + assertThat(url.upload("http://otherhost:8080/cms/upload/a.jpg")).isEqualTo("http://otherhost:8080/cms/upload/a.jpg"); + assertThat(url.upload(null)).isNull(); + assertThat(url.upload("")).isEqualTo(""); + assertThat(url.upload(" ")).isEqualTo(" "); + } + + @Test + @DisplayName("rewriteUploadUrls:正文内相对/历史绝对上传地址统一重写为当前基准,外链不动") + void rewriteUploadUrls_normalizesLegacyAndRelative() { + PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn", "demo", "default"); + String html = "<p><img src=\"/cms/upload/cms/demo/202609/a.jpg\"></p>" + + "<p><img src=\"http://localhost:8072/cms/upload/cms/demo/202609/b.jpg\"></p>" + + "<p><img src=\"https://cdn.example.com/x.png\"></p>"; + String rewritten = url.rewriteUploadUrls(html); + assertThat(rewritten).contains("src=\"https://www.xxx.edu.cn/cms/upload/cms/demo/202609/a.jpg\""); + assertThat(rewritten).contains("src=\"https://www.xxx.edu.cn/cms/upload/cms/demo/202609/b.jpg\""); + assertThat(rewritten).doesNotContain("localhost:8072"); + assertThat(rewritten).contains("src=\"https://cdn.example.com/x.png\""); + assertThat(url.rewriteUploadUrls(null)).isNull(); + } + + // ===== siteHome 分支(B-4:学校域形态下未配域子站不再退化为 "/")===== + + @Test + @DisplayName("siteHome 四分支:目标配域 / base域替换站点段 / 学校域退化 base 域规范路径 / 无配置回 /") + void siteHome_branches() { + PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn", "main", "default", + "https://base.yunhaixingtong.com/cms"); + // ① 目标站配置 public_base_url:以它为准(空白视同未配) + assertThat(url.siteHome("jdx", "https://jdx.xxx.edu.cn")).isEqualTo("https://jdx.xxx.edu.cn/"); + assertThat(url.siteHome("jdx", " ")).isEqualTo("https://base.yunhaixingtong.com/cms/portal/jdx/"); + // ② 学校域形态(当前基准无 /portal/ 段)+ 目标未配域 → base 域规范路径(B-4 修复) + assertThat(url.siteHome("jdx", null)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/jdx/"); + + // ③ base 域子路径形态:替换站点段(既有行为不变) + PortalUrlHelper sub = new PortalUrlHelper("https://base.yunhaixingtong.com/cms/portal/main", "main", + "default", "https://base.yunhaixingtong.com/cms"); + assertThat(sub.siteHome("jdx", null)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/jdx/"); + + // ④ 学校域形态 + base-origin 未配置 → 退回 "/"(现状兜底) + PortalUrlHelper noBase = new PortalUrlHelper("https://www.xxx.edu.cn", "main", "default", null); + assertThat(noBase.siteHome("jdx", null)).isEqualTo("/"); + } + + @Test + @DisplayName("fromBaseOrigin:C-6 未配 public_base_url 时派生 base 域规范路径") + void fromBaseOrigin_derivesCanonicalBaseDomainPath() { + PortalUrlHelper url = PortalUrlHelper.fromBaseOrigin( + "https://base.yunhaixingtong.com/cms/", "demo", "default"); + assertThat(url.home()).isEqualTo("https://base.yunhaixingtong.com/cms/portal/demo/"); + assertThat(url.category(3)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/demo/c/3"); + assertThat(url.asset("css/main.css")) + .isEqualTo("https://base.yunhaixingtong.com/cms/portal/demo/assets/default/css/main.css"); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..d3b0dc9 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,94 @@ +# ===== 单测环境配置(覆盖 src/main/resources/application.yml)===== +# 目标:本地 MySQL 8.0.45(JSON 列原生类型,与 schema-cms-test.sql / cms_init.sql 一致)+ 本地 Redis 6379 +# schema 初始化在 JPA EntityManagerFactory 之前执行(Boot 2.7 SQL init 时序), +# 表已存在时 hibernate ddl-auto=update 不会重建/改列。 +server: + port: 0 + +spring: + application: + name: cms + datasource: + url: jdbc:mysql://localhost:3306/bsch_cms_test?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + sql: + init: + mode: always + schema-locations: classpath:schema-cms-test.sql + jpa: + hibernate: + ddl-auto: update + show-sql: false + open-in-view: false + properties: + hibernate: + jdbc: + time_zone: Asia/Shanghai + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + thymeleaf: + cache: true + prefix: classpath:/templates/ + suffix: .html + encoding: UTF-8 + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + cache: + type: caffeine + caffeine: + spec: maximumSize=10000,expireAfterWrite=5m + +yh: + auth: + enabled: true + sso: + server-url-prefix: http://sso.yunhaixingtong.com/cas/ + client-host-url: http://localhost:8072/cms + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + app-key: 100XXX + app-secret: xxxxxxxx + open: + enabled: true + base-host: http://base.yunhaixingtong.com + base-path: /userbase + app-key: 100XXX + app-secret: xxxxxxxx + +cms: + # 超管判定:BoaUser.type == 1(夹具 TestUsers.superAdmin 已对齐);校级角色经 BoaRoleInfo.schId 匹配 + portal: + detail-max-age: 300 + +# 上传落到工程 target 目录,测试后随 mvn clean 清除 +FILE_PATH: ./target/test-upload/ +FILE_WEB_PATH: /cms/upload/ + +logging: + level: + com.yunhai.cms: info + org.hibernate.SQL: warn + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never + +# 本地 Redis(单测 Redis 演进用例连接参数) +cms.test.redis: + host: localhost + port: 6379 + password: 123456 diff --git a/src/test/resources/schema-cms-test.sql b/src/test/resources/schema-cms-test.sql new file mode 100644 index 0000000..8cf9a7f --- /dev/null +++ b/src/test/resources/schema-cms-test.sql @@ -0,0 +1,134 @@ +-- ============================================================ +-- bsch_cms_test 单测库表结构(每次 Spring 上下文启动先 DROP 重建,保证干净起点) +-- 以 src/main/resources/sql/cms_init.sql 为蓝本; +-- 2026-09-05 起本机为 MySQL 8.0.45,JSON 列恢复原生类型(与 cms_init.sql / 实体 columnDefinition 一致) +-- ============================================================ + +DROP TABLE IF EXISTS `cms_attachment`; +DROP TABLE IF EXISTS `cms_article`; +DROP TABLE IF EXISTS `cms_category`; +DROP TABLE IF EXISTS `cms_banner`; +DROP TABLE IF EXISTS `cms_link`; +DROP TABLE IF EXISTS `cms_site`; + +CREATE TABLE `cms_site` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `code` VARCHAR(64) NOT NULL COMMENT '站点编码,URL 路径用(/portal/{code})', + `parent_site_id` INT NOT NULL DEFAULT 0 COMMENT '上级站点ID(0=顶层校站点;院系子站挂主站 id)', + `sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)', + `sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)', + `name` VARCHAR(128) NOT NULL COMMENT '站点名称', + `theme` VARCHAR(64) NULL DEFAULT 'default' COMMENT '皮肤包名', + `config_json` JSON NULL COMMENT '站点配置(logo/联系方式/页脚等;预留 wechat 节点)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_site_code` (`code`), + KEY `idx_site_sch` (`sch_id`), + KEY `idx_site_parent` (`parent_site_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点'; + +CREATE TABLE `cms_category` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `parent_id` INT NOT NULL DEFAULT 0 COMMENT '父栏目ID(0=根)', + `name` VARCHAR(64) NOT NULL COMMENT '栏目名称', + `type` TINYINT NOT NULL DEFAULT 1 COMMENT '栏目类型:1=文章列表 2=单页 3=外链', + `link_url` VARCHAR(500) NULL COMMENT '外链地址(type=3 必填)', + `template` VARCHAR(64) NULL COMMENT '栏目模板覆盖', + `content` LONGTEXT NULL COMMENT '单页栏目内容', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '同级排序(升序)', + `seo_title` VARCHAR(200) NULL COMMENT 'SEO 标题', + `seo_description` VARCHAR(500) NULL COMMENT 'SEO 描述', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0=隐藏 1=显示', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_cat_tree` (`site_id`, `parent_id`, `sort_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 栏目'; + +CREATE TABLE `cms_article` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `category_id` INT NOT NULL COMMENT '所属栏目', + `title` VARCHAR(200) NOT NULL COMMENT '标题', + `summary` VARCHAR(500) NULL COMMENT '摘要', + `content` LONGTEXT NULL COMMENT '正文 HTML', + `cover_image` VARCHAR(500) NULL COMMENT '封面图 URL', + `author_account` VARCHAR(64) NULL COMMENT '作者账号', + `author_name` VARCHAR(64) NULL COMMENT '作者姓名', + `source` VARCHAR(128) NULL COMMENT '来源', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=草稿 1=已发布 2=已下架', + `topmost` TINYINT NOT NULL DEFAULT 0 COMMENT '置顶:0/1', + `publish_time` DATETIME NULL COMMENT '发布时间', + `offline_time` DATETIME NULL COMMENT '下架时间', + `view_count` INT NOT NULL DEFAULT 0 COMMENT '浏览量', + `ext_json` JSON NULL COMMENT '扩展字段(保留键:wechat=推送回执预留、albums=图集演进)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_art_list` (`site_id`, `category_id`, `status`, `publish_time`), + KEY `idx_art_site_top` (`site_id`, `status`, `topmost`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 文章'; + +CREATE TABLE `cms_attachment` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `article_id` INT NULL COMMENT '关联文章', + `file_name` VARCHAR(255) NOT NULL COMMENT '原始文件名', + `file_path` VARCHAR(500) NOT NULL COMMENT '存储路径', + `web_url` VARCHAR(500) NOT NULL COMMENT 'Web 访问 URL', + `file_size` BIGINT NULL COMMENT '字节数', + `mime_type` VARCHAR(128) NULL COMMENT 'MIME 类型', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_att_article` (`article_id`), + KEY `idx_att_site` (`site_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 附件'; + +CREATE TABLE `cms_banner` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `position` VARCHAR(32) NOT NULL DEFAULT 'home' COMMENT '展示位标识', + `title` VARCHAR(200) NOT NULL COMMENT '标题', + `image_url` VARCHAR(500) NOT NULL COMMENT '图片 URL', + `link_type` TINYINT NOT NULL DEFAULT 1 COMMENT '跳转类型:1=文章 2=栏目 3=外链', + `link_value` VARCHAR(500) NULL COMMENT '跳转目标', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '同级排序(升序)', + `start_time` DATETIME NULL COMMENT '上线时间', + `end_time` DATETIME NULL COMMENT '下线时间', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0=停用 1=启用', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_banner` (`site_id`, `position`, `status`, `sort_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 轮播/推荐位'; + +CREATE TABLE `cms_link` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `group_name` VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '分组', + `name` VARCHAR(128) NOT NULL COMMENT '链接名称', + `logo_url` VARCHAR(500) NULL COMMENT 'Logo URL', + `link_url` VARCHAR(500) NOT NULL COMMENT '链接地址', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '组内排序', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0=隐藏 1=显示', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_link` (`site_id`, `group_name`, `sort_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 友情链接'; diff --git a/src/test/resources/templates/themes/t-bad-cssvar/assets/css/main.css b/src/test/resources/templates/themes/t-bad-cssvar/assets/css/main.css new file mode 100644 index 0000000..3a9b9fd --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-cssvar/assets/css/main.css @@ -0,0 +1,324 @@ +/* ============================================================ + * default 皮肤包样式(方案 §8.2) + * 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪,D2 决策点确认)。 + * ============================================================ */ +:root { + --primary: #1a5cb0; + --primary-dark: #12447f; + --text: #333; + --text-light: #777; + --bg: #f5f6f8; + --card-bg: #fff; + --border: #e5e6eb; + --max-width: 1200px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} +a { color: inherit; text-decoration: none; } +a:hover { color: var(--primary); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { color: var(--text-light); text-align: center; padding: 32px 0; } + +/* ===== 头部 ===== */ +.site-header { background: var(--card-bg); border-bottom: 1px solid var(--border); } +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.site-brand { display: flex; align-items: center; gap: 12px; } +.site-logo { height: 44px; } +.site-name { font-size: 26px; font-weight: 700; color: var(--primary); } +.site-search { display: flex; } +.site-search input { + border: 1px solid var(--border); + border-right: none; + padding: 8px 12px; + width: 220px; + outline: none; +} +.site-search button { + border: none; + background: var(--primary); + color: #fff; + padding: 8px 18px; + cursor: pointer; +} + +/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */ +/* 悬停展开白底纵向列表,右对齐 + 超高滚动(院系数十个子站场景) */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + padding: 8px 14px; + cursor: pointer; + color: var(--primary); + font-weight: 600; + white-space: nowrap; +} +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 110; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: 9px 16px; + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: #f2f6fc; color: var(--primary); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } +.site-nav { background: var(--primary); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + flex-wrap: wrap; +} +.site-nav li a { + display: block; + padding: 12px 22px; + color: #fff; +} +.site-nav li:hover a, .site-nav li.active a { background: var(--primary-dark); } + +/* ===== 二级下拉导航 ===== */ +/* 面板为白底纵向列表:需整体覆盖 .site-nav ul 的横向 flex 与内边距 */ +.site-nav > ul > li { position: relative; } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .85; } +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 168px; + max-width: none; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 100; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: 9px 16px; + color: var(--text); + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a:hover { background: #f2f6fc; color: var(--primary); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播 ===== */ +.banner { position: relative; max-width: var(--max-width); margin: 16px auto; overflow: hidden; } +.banner-slides { position: relative; height: 420px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 12px 20px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, .6)); +} +.banner-dots { + position: absolute; + bottom: 12px; right: 20px; + display: flex; gap: 8px; +} +.banner-dots button { + width: 10px; height: 10px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .6); + cursor: pointer; +} +.banner-dots button.active { background: #fff; } + +/* ===== 通用区块 ===== */ +.top-strip, .category-block { + max-width: var(--max-width); + margin: 16px auto; + padding: 20px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.section-title, .block-head h2 { + margin: 0 0 12px; + font-size: 20px; + border-left: 4px solid var(--primary); + padding-left: 10px; +} +.block-head { display: flex; align-items: baseline; justify-content: space-between; } +.more { font-size: 13px; color: var(--text-light); } +.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; } +.top-strip li, .block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px dashed var(--border); +} +.top-strip li:last-child, .block-list li:last-child { border-bottom: none; } +.top-strip li a, .block-list li a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-badge { + flex: none; + color: #fff; + background: var(--primary); + font-size: 12px; + padding: 1px 6px; + border-radius: 2px; +} + +/* ===== 列表页 ===== */ +.list-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 24px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 16px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; } +.page-title { + font-size: 22px; + border-left: 4px solid var(--primary); + padding-left: 10px; + margin: 0 0 18px; +} +.article-list { list-style: none; margin: 0; padding: 0; } +.article-list li { padding: 14px 0; border-bottom: 1px dashed var(--border); } +.item-title { font-size: 17px; } +.item-summary { + color: var(--text-light); + font-size: 14px; + margin: 6px 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.item-meta { display: flex; gap: 10px; align-items: center; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: 6px; justify-content: center; padding: 24px 0 4px; } +.pagination a { + min-width: 34px; + text-align: center; + padding: 6px 10px; + border: 1px solid var(--border); + background: var(--card-bg); +} +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情/单页 ===== */ +.detail-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.article-title { margin: 8px 0 12px; font-size: 26px; text-align: center; } +.article-meta { + display: flex; + gap: 18px; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.article-content { line-height: 1.9; font-size: 16px; padding-top: 20px; word-break: break-word; } +.article-content img { height: auto; } +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; } +.article-sibling { display: flex; flex-direction: column; gap: 8px; margin-top: 28px; font-size: 14px; } + +/* ===== 页脚 ===== */ +.site-footer { margin-top: 32px; background: var(--primary-dark); color: rgba(255, 255, 255, .85); } +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; } +.group-name { font-weight: 600; margin-right: 4px; } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 12px 16px 20px; + text-align: center; + font-size: 13px; +} +.footer-info p { margin: 4px 0; } + +/* ===== 移动端适配 ===== */ +@media (max-width: 768px) { + .header-inner { flex-direction: column; align-items: stretch; } + .site-search input { flex: 1; width: auto; } + .site-colleges { align-self: flex-start; } + .site-colleges .colleges-menu { left: 0; right: auto; min-width: 100%; } + .banner-slides { height: 200px; } + .detail-main, .list-main { padding: 18px 14px; } + .article-title { font-size: 22px; } + /* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: transparent; + min-width: 0; + padding: 0 0 6px; + } + .site-nav .sub-nav li a { color: rgba(255, 255, 255, .9); padding: 8px 16px 8px 34px; } + .site-nav .sub-nav li a:hover { background: var(--primary-dark); color: #fff; } +} diff --git a/src/test/resources/templates/themes/t-bad-cssvar/assets/screenshot.png b/src/test/resources/templates/themes/t-bad-cssvar/assets/screenshot.png new file mode 100644 index 0000000..889b91e Binary files /dev/null and b/src/test/resources/templates/themes/t-bad-cssvar/assets/screenshot.png differ diff --git a/src/test/resources/templates/themes/t-bad-cssvar/detail.html b/src/test/resources/templates/themes/t-bad-cssvar/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-cssvar/detail.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN"> +<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head> +<body> +<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header> + +<main class="detail-main"> + <nav class="breadcrumb"> + <a th:href="${url.home()}">首页</a> + <a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</a> + <span>正文</span> + </nav> + + <article class="article"> + <h1 class="article-title" th:text="${article.title}">文章标题</h1> + <div class="article-meta"> + <time th:if="${article.publishTime != null}" + th:text="${'发布时间:' + #temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time> + <span th:if="${!#strings.isEmpty(article.authorName)}" + th:text="'作者:' + ${article.authorName}">作者</span> + <span th:if="${!#strings.isEmpty(article.source)}" + th:text="'来源:' + ${article.source}">来源</span> + <span th:text="'浏览量:' + ${article.viewCount}">浏览量</span> + </div> + <!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext; + 其余字段一律 th:text 转义输出 --> + <div class="article-content" th:utext="${article.content}">正文</div> + </article> + + <nav class="article-sibling"> + <a th:if="${prevArticle != null}" class="prev" + th:href="${url.article(prevArticle.id)}" + th:text="'上一篇:' + ${prevArticle.title}">上一篇</a> + <a th:if="${nextArticle != null}" class="next" + th:href="${url.article(nextArticle.id)}" + th:text="'下一篇:' + ${nextArticle.title}">下一篇</a> + </nav> +</main> + +<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer> +</body> +</html> diff --git a/src/test/resources/templates/themes/t-bad-cssvar/fragments/footer.html b/src/test/resources/templates/themes/t-bad-cssvar/fragments/footer.html new file mode 100644 index 0000000..5c8e713 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-cssvar/fragments/footer.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html xmlns:th="http://www.thymeleaf.org"> +<!-- 公共页脚:联系信息 + 友情链接(分组渲染,方案 §8.1:友链不设独立路由) --> +<footer class="site-footer" th:fragment="footer"> + <div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}"> + <div class="links-group" th:each="group : ${site.linkGroups}"> + <span class="group-name" th:text="${group.groupName}">分组</span> + <a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener" + th:text="${link.name}">链接</a> + </div> + </div> + <div class="footer-info"> + <p th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p> + <p th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p> + <p th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p> + </div> +</footer> +</html> diff --git a/src/test/resources/templates/themes/t-bad-cssvar/fragments/head.html b/src/test/resources/templates/themes/t-bad-cssvar/fragments/head.html new file mode 100644 index 0000000..a6bcf87 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-cssvar/fragments/head.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN"> +<!-- 公共 head 片段:SEO(title/description/canonical)+ 主题资产(方案 §8.3/§8.6) --> +<head th:fragment="head"> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title th:text="${pageTitle}">页面标题 + + + + + + diff --git a/src/test/resources/templates/themes/t-bad-cssvar/fragments/header.html b/src/test/resources/templates/themes/t-bad-cssvar/fragments/header.html new file mode 100644 index 0000000..a79a173 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-cssvar/fragments/header.html @@ -0,0 +1,53 @@ + + + + + diff --git a/src/test/resources/templates/themes/t-bad-cssvar/home.html b/src/test/resources/templates/themes/t-bad-cssvar/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-cssvar/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-bad-cssvar/list.html b/src/test/resources/templates/themes/t-bad-cssvar/list.html new file mode 100644 index 0000000..fec0f2c --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-cssvar/list.html @@ -0,0 +1,50 @@ + + + + +
+ +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-bad-cssvar/single.html b/src/test/resources/templates/themes/t-bad-cssvar/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-cssvar/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/test/resources/templates/themes/t-bad-cssvar/theme.json b/src/test/resources/templates/themes/t-bad-cssvar/theme.json new file mode 100644 index 0000000..4300941 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-cssvar/theme.json @@ -0,0 +1,10 @@ +{ + "specVersion": 1, + "name": "t-bad-cssvar", + "title": "cssVar 出现在非 color 参数(夹具)", + "version": "1.0.0", + "screenshot": "assets/screenshot.png", + "params": [ + { "key": "footText", "cssVar": "--foot-text", "label": "cssVar 用在 text 类型上", "type": "text", "default": "x" } + ] +} diff --git a/src/test/resources/templates/themes/t-bad-key/assets/css/main.css b/src/test/resources/templates/themes/t-bad-key/assets/css/main.css new file mode 100644 index 0000000..3a9b9fd --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-key/assets/css/main.css @@ -0,0 +1,324 @@ +/* ============================================================ + * default 皮肤包样式(方案 §8.2) + * 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪,D2 决策点确认)。 + * ============================================================ */ +:root { + --primary: #1a5cb0; + --primary-dark: #12447f; + --text: #333; + --text-light: #777; + --bg: #f5f6f8; + --card-bg: #fff; + --border: #e5e6eb; + --max-width: 1200px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} +a { color: inherit; text-decoration: none; } +a:hover { color: var(--primary); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { color: var(--text-light); text-align: center; padding: 32px 0; } + +/* ===== 头部 ===== */ +.site-header { background: var(--card-bg); border-bottom: 1px solid var(--border); } +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.site-brand { display: flex; align-items: center; gap: 12px; } +.site-logo { height: 44px; } +.site-name { font-size: 26px; font-weight: 700; color: var(--primary); } +.site-search { display: flex; } +.site-search input { + border: 1px solid var(--border); + border-right: none; + padding: 8px 12px; + width: 220px; + outline: none; +} +.site-search button { + border: none; + background: var(--primary); + color: #fff; + padding: 8px 18px; + cursor: pointer; +} + +/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */ +/* 悬停展开白底纵向列表,右对齐 + 超高滚动(院系数十个子站场景) */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + padding: 8px 14px; + cursor: pointer; + color: var(--primary); + font-weight: 600; + white-space: nowrap; +} +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 110; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: 9px 16px; + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: #f2f6fc; color: var(--primary); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } +.site-nav { background: var(--primary); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + flex-wrap: wrap; +} +.site-nav li a { + display: block; + padding: 12px 22px; + color: #fff; +} +.site-nav li:hover a, .site-nav li.active a { background: var(--primary-dark); } + +/* ===== 二级下拉导航 ===== */ +/* 面板为白底纵向列表:需整体覆盖 .site-nav ul 的横向 flex 与内边距 */ +.site-nav > ul > li { position: relative; } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .85; } +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 168px; + max-width: none; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 100; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: 9px 16px; + color: var(--text); + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a:hover { background: #f2f6fc; color: var(--primary); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播 ===== */ +.banner { position: relative; max-width: var(--max-width); margin: 16px auto; overflow: hidden; } +.banner-slides { position: relative; height: 420px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 12px 20px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, .6)); +} +.banner-dots { + position: absolute; + bottom: 12px; right: 20px; + display: flex; gap: 8px; +} +.banner-dots button { + width: 10px; height: 10px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .6); + cursor: pointer; +} +.banner-dots button.active { background: #fff; } + +/* ===== 通用区块 ===== */ +.top-strip, .category-block { + max-width: var(--max-width); + margin: 16px auto; + padding: 20px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.section-title, .block-head h2 { + margin: 0 0 12px; + font-size: 20px; + border-left: 4px solid var(--primary); + padding-left: 10px; +} +.block-head { display: flex; align-items: baseline; justify-content: space-between; } +.more { font-size: 13px; color: var(--text-light); } +.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; } +.top-strip li, .block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px dashed var(--border); +} +.top-strip li:last-child, .block-list li:last-child { border-bottom: none; } +.top-strip li a, .block-list li a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-badge { + flex: none; + color: #fff; + background: var(--primary); + font-size: 12px; + padding: 1px 6px; + border-radius: 2px; +} + +/* ===== 列表页 ===== */ +.list-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 24px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 16px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; } +.page-title { + font-size: 22px; + border-left: 4px solid var(--primary); + padding-left: 10px; + margin: 0 0 18px; +} +.article-list { list-style: none; margin: 0; padding: 0; } +.article-list li { padding: 14px 0; border-bottom: 1px dashed var(--border); } +.item-title { font-size: 17px; } +.item-summary { + color: var(--text-light); + font-size: 14px; + margin: 6px 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.item-meta { display: flex; gap: 10px; align-items: center; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: 6px; justify-content: center; padding: 24px 0 4px; } +.pagination a { + min-width: 34px; + text-align: center; + padding: 6px 10px; + border: 1px solid var(--border); + background: var(--card-bg); +} +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情/单页 ===== */ +.detail-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.article-title { margin: 8px 0 12px; font-size: 26px; text-align: center; } +.article-meta { + display: flex; + gap: 18px; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.article-content { line-height: 1.9; font-size: 16px; padding-top: 20px; word-break: break-word; } +.article-content img { height: auto; } +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; } +.article-sibling { display: flex; flex-direction: column; gap: 8px; margin-top: 28px; font-size: 14px; } + +/* ===== 页脚 ===== */ +.site-footer { margin-top: 32px; background: var(--primary-dark); color: rgba(255, 255, 255, .85); } +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; } +.group-name { font-weight: 600; margin-right: 4px; } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 12px 16px 20px; + text-align: center; + font-size: 13px; +} +.footer-info p { margin: 4px 0; } + +/* ===== 移动端适配 ===== */ +@media (max-width: 768px) { + .header-inner { flex-direction: column; align-items: stretch; } + .site-search input { flex: 1; width: auto; } + .site-colleges { align-self: flex-start; } + .site-colleges .colleges-menu { left: 0; right: auto; min-width: 100%; } + .banner-slides { height: 200px; } + .detail-main, .list-main { padding: 18px 14px; } + .article-title { font-size: 22px; } + /* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: transparent; + min-width: 0; + padding: 0 0 6px; + } + .site-nav .sub-nav li a { color: rgba(255, 255, 255, .9); padding: 8px 16px 8px 34px; } + .site-nav .sub-nav li a:hover { background: var(--primary-dark); color: #fff; } +} diff --git a/src/test/resources/templates/themes/t-bad-key/assets/screenshot.png b/src/test/resources/templates/themes/t-bad-key/assets/screenshot.png new file mode 100644 index 0000000..889b91e Binary files /dev/null and b/src/test/resources/templates/themes/t-bad-key/assets/screenshot.png differ diff --git a/src/test/resources/templates/themes/t-bad-key/detail.html b/src/test/resources/templates/themes/t-bad-key/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-key/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/test/resources/templates/themes/t-bad-key/fragments/footer.html b/src/test/resources/templates/themes/t-bad-key/fragments/footer.html new file mode 100644 index 0000000..5c8e713 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-key/fragments/footer.html @@ -0,0 +1,18 @@ + + + +
+ + +
+ diff --git a/src/test/resources/templates/themes/t-bad-key/fragments/head.html b/src/test/resources/templates/themes/t-bad-key/fragments/head.html new file mode 100644 index 0000000..a6bcf87 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-key/fragments/head.html @@ -0,0 +1,14 @@ + + + + + + + 页面标题 + + + + + + diff --git a/src/test/resources/templates/themes/t-bad-key/fragments/header.html b/src/test/resources/templates/themes/t-bad-key/fragments/header.html new file mode 100644 index 0000000..a79a173 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-key/fragments/header.html @@ -0,0 +1,53 @@ + + + + + diff --git a/src/test/resources/templates/themes/t-bad-key/home.html b/src/test/resources/templates/themes/t-bad-key/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-key/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-bad-key/list.html b/src/test/resources/templates/themes/t-bad-key/list.html new file mode 100644 index 0000000..fec0f2c --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-key/list.html @@ -0,0 +1,50 @@ + + + + +
+ +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-bad-key/single.html b/src/test/resources/templates/themes/t-bad-key/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-key/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/test/resources/templates/themes/t-bad-key/theme.json b/src/test/resources/templates/themes/t-bad-key/theme.json new file mode 100644 index 0000000..daac3ef --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-key/theme.json @@ -0,0 +1,10 @@ +{ + "specVersion": 1, + "name": "t-bad-key", + "title": "参数 key 非法(夹具)", + "version": "1.0.0", + "screenshot": "assets/screenshot.png", + "params": [ + { "key": "bad-key", "label": "非法 key(含短横线)", "type": "color", "default": "#123456" } + ] +} diff --git a/src/test/resources/templates/themes/t-bad-shot/assets/css/main.css b/src/test/resources/templates/themes/t-bad-shot/assets/css/main.css new file mode 100644 index 0000000..3a9b9fd --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-shot/assets/css/main.css @@ -0,0 +1,324 @@ +/* ============================================================ + * default 皮肤包样式(方案 §8.2) + * 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪,D2 决策点确认)。 + * ============================================================ */ +:root { + --primary: #1a5cb0; + --primary-dark: #12447f; + --text: #333; + --text-light: #777; + --bg: #f5f6f8; + --card-bg: #fff; + --border: #e5e6eb; + --max-width: 1200px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} +a { color: inherit; text-decoration: none; } +a:hover { color: var(--primary); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { color: var(--text-light); text-align: center; padding: 32px 0; } + +/* ===== 头部 ===== */ +.site-header { background: var(--card-bg); border-bottom: 1px solid var(--border); } +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.site-brand { display: flex; align-items: center; gap: 12px; } +.site-logo { height: 44px; } +.site-name { font-size: 26px; font-weight: 700; color: var(--primary); } +.site-search { display: flex; } +.site-search input { + border: 1px solid var(--border); + border-right: none; + padding: 8px 12px; + width: 220px; + outline: none; +} +.site-search button { + border: none; + background: var(--primary); + color: #fff; + padding: 8px 18px; + cursor: pointer; +} + +/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */ +/* 悬停展开白底纵向列表,右对齐 + 超高滚动(院系数十个子站场景) */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + padding: 8px 14px; + cursor: pointer; + color: var(--primary); + font-weight: 600; + white-space: nowrap; +} +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 110; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: 9px 16px; + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: #f2f6fc; color: var(--primary); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } +.site-nav { background: var(--primary); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + flex-wrap: wrap; +} +.site-nav li a { + display: block; + padding: 12px 22px; + color: #fff; +} +.site-nav li:hover a, .site-nav li.active a { background: var(--primary-dark); } + +/* ===== 二级下拉导航 ===== */ +/* 面板为白底纵向列表:需整体覆盖 .site-nav ul 的横向 flex 与内边距 */ +.site-nav > ul > li { position: relative; } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .85; } +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 168px; + max-width: none; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 100; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: 9px 16px; + color: var(--text); + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a:hover { background: #f2f6fc; color: var(--primary); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播 ===== */ +.banner { position: relative; max-width: var(--max-width); margin: 16px auto; overflow: hidden; } +.banner-slides { position: relative; height: 420px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 12px 20px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, .6)); +} +.banner-dots { + position: absolute; + bottom: 12px; right: 20px; + display: flex; gap: 8px; +} +.banner-dots button { + width: 10px; height: 10px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .6); + cursor: pointer; +} +.banner-dots button.active { background: #fff; } + +/* ===== 通用区块 ===== */ +.top-strip, .category-block { + max-width: var(--max-width); + margin: 16px auto; + padding: 20px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.section-title, .block-head h2 { + margin: 0 0 12px; + font-size: 20px; + border-left: 4px solid var(--primary); + padding-left: 10px; +} +.block-head { display: flex; align-items: baseline; justify-content: space-between; } +.more { font-size: 13px; color: var(--text-light); } +.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; } +.top-strip li, .block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px dashed var(--border); +} +.top-strip li:last-child, .block-list li:last-child { border-bottom: none; } +.top-strip li a, .block-list li a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-badge { + flex: none; + color: #fff; + background: var(--primary); + font-size: 12px; + padding: 1px 6px; + border-radius: 2px; +} + +/* ===== 列表页 ===== */ +.list-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 24px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 16px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; } +.page-title { + font-size: 22px; + border-left: 4px solid var(--primary); + padding-left: 10px; + margin: 0 0 18px; +} +.article-list { list-style: none; margin: 0; padding: 0; } +.article-list li { padding: 14px 0; border-bottom: 1px dashed var(--border); } +.item-title { font-size: 17px; } +.item-summary { + color: var(--text-light); + font-size: 14px; + margin: 6px 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.item-meta { display: flex; gap: 10px; align-items: center; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: 6px; justify-content: center; padding: 24px 0 4px; } +.pagination a { + min-width: 34px; + text-align: center; + padding: 6px 10px; + border: 1px solid var(--border); + background: var(--card-bg); +} +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情/单页 ===== */ +.detail-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.article-title { margin: 8px 0 12px; font-size: 26px; text-align: center; } +.article-meta { + display: flex; + gap: 18px; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.article-content { line-height: 1.9; font-size: 16px; padding-top: 20px; word-break: break-word; } +.article-content img { height: auto; } +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; } +.article-sibling { display: flex; flex-direction: column; gap: 8px; margin-top: 28px; font-size: 14px; } + +/* ===== 页脚 ===== */ +.site-footer { margin-top: 32px; background: var(--primary-dark); color: rgba(255, 255, 255, .85); } +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; } +.group-name { font-weight: 600; margin-right: 4px; } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 12px 16px 20px; + text-align: center; + font-size: 13px; +} +.footer-info p { margin: 4px 0; } + +/* ===== 移动端适配 ===== */ +@media (max-width: 768px) { + .header-inner { flex-direction: column; align-items: stretch; } + .site-search input { flex: 1; width: auto; } + .site-colleges { align-self: flex-start; } + .site-colleges .colleges-menu { left: 0; right: auto; min-width: 100%; } + .banner-slides { height: 200px; } + .detail-main, .list-main { padding: 18px 14px; } + .article-title { font-size: 22px; } + /* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: transparent; + min-width: 0; + padding: 0 0 6px; + } + .site-nav .sub-nav li a { color: rgba(255, 255, 255, .9); padding: 8px 16px 8px 34px; } + .site-nav .sub-nav li a:hover { background: var(--primary-dark); color: #fff; } +} diff --git a/src/test/resources/templates/themes/t-bad-shot/assets/screenshot.png b/src/test/resources/templates/themes/t-bad-shot/assets/screenshot.png new file mode 100644 index 0000000..889b91e Binary files /dev/null and b/src/test/resources/templates/themes/t-bad-shot/assets/screenshot.png differ diff --git a/src/test/resources/templates/themes/t-bad-shot/detail.html b/src/test/resources/templates/themes/t-bad-shot/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-shot/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/test/resources/templates/themes/t-bad-shot/fragments/footer.html b/src/test/resources/templates/themes/t-bad-shot/fragments/footer.html new file mode 100644 index 0000000..5c8e713 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-shot/fragments/footer.html @@ -0,0 +1,18 @@ + + + +
+ + +
+ diff --git a/src/test/resources/templates/themes/t-bad-shot/fragments/head.html b/src/test/resources/templates/themes/t-bad-shot/fragments/head.html new file mode 100644 index 0000000..a6bcf87 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-shot/fragments/head.html @@ -0,0 +1,14 @@ + + + + + + + 页面标题 + + + + + + diff --git a/src/test/resources/templates/themes/t-bad-shot/fragments/header.html b/src/test/resources/templates/themes/t-bad-shot/fragments/header.html new file mode 100644 index 0000000..a79a173 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-shot/fragments/header.html @@ -0,0 +1,53 @@ + + + + + diff --git a/src/test/resources/templates/themes/t-bad-shot/home.html b/src/test/resources/templates/themes/t-bad-shot/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-shot/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-bad-shot/list.html b/src/test/resources/templates/themes/t-bad-shot/list.html new file mode 100644 index 0000000..fec0f2c --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-shot/list.html @@ -0,0 +1,50 @@ + + + + +
+ +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-bad-shot/single.html b/src/test/resources/templates/themes/t-bad-shot/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-shot/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/test/resources/templates/themes/t-bad-shot/theme.json b/src/test/resources/templates/themes/t-bad-shot/theme.json new file mode 100644 index 0000000..774ff5f --- /dev/null +++ b/src/test/resources/templates/themes/t-bad-shot/theme.json @@ -0,0 +1,8 @@ +{ + "specVersion": 1, + "name": "t-bad-shot", + "title": "声明截图缺失(夹具)", + "version": "1.0.0", + "screenshot": "assets/no-such-shot.png", + "params": [] +} diff --git a/src/test/resources/templates/themes/t-broken-json/home.html b/src/test/resources/templates/themes/t-broken-json/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/test/resources/templates/themes/t-broken-json/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-broken-json/theme.json b/src/test/resources/templates/themes/t-broken-json/theme.json new file mode 100644 index 0000000..3c99873 --- /dev/null +++ b/src/test/resources/templates/themes/t-broken-json/theme.json @@ -0,0 +1 @@ +{ this is not valid json *** diff --git a/src/test/resources/templates/themes/t-demo-red/assets/css/main.css b/src/test/resources/templates/themes/t-demo-red/assets/css/main.css new file mode 100644 index 0000000..3a9b9fd --- /dev/null +++ b/src/test/resources/templates/themes/t-demo-red/assets/css/main.css @@ -0,0 +1,324 @@ +/* ============================================================ + * default 皮肤包样式(方案 §8.2) + * 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪,D2 决策点确认)。 + * ============================================================ */ +:root { + --primary: #1a5cb0; + --primary-dark: #12447f; + --text: #333; + --text-light: #777; + --bg: #f5f6f8; + --card-bg: #fff; + --border: #e5e6eb; + --max-width: 1200px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} +a { color: inherit; text-decoration: none; } +a:hover { color: var(--primary); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { color: var(--text-light); text-align: center; padding: 32px 0; } + +/* ===== 头部 ===== */ +.site-header { background: var(--card-bg); border-bottom: 1px solid var(--border); } +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.site-brand { display: flex; align-items: center; gap: 12px; } +.site-logo { height: 44px; } +.site-name { font-size: 26px; font-weight: 700; color: var(--primary); } +.site-search { display: flex; } +.site-search input { + border: 1px solid var(--border); + border-right: none; + padding: 8px 12px; + width: 220px; + outline: none; +} +.site-search button { + border: none; + background: var(--primary); + color: #fff; + padding: 8px 18px; + cursor: pointer; +} + +/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */ +/* 悬停展开白底纵向列表,右对齐 + 超高滚动(院系数十个子站场景) */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + padding: 8px 14px; + cursor: pointer; + color: var(--primary); + font-weight: 600; + white-space: nowrap; +} +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 110; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: 9px 16px; + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: #f2f6fc; color: var(--primary); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } +.site-nav { background: var(--primary); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + flex-wrap: wrap; +} +.site-nav li a { + display: block; + padding: 12px 22px; + color: #fff; +} +.site-nav li:hover a, .site-nav li.active a { background: var(--primary-dark); } + +/* ===== 二级下拉导航 ===== */ +/* 面板为白底纵向列表:需整体覆盖 .site-nav ul 的横向 flex 与内边距 */ +.site-nav > ul > li { position: relative; } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .85; } +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 168px; + max-width: none; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 100; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: 9px 16px; + color: var(--text); + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a:hover { background: #f2f6fc; color: var(--primary); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播 ===== */ +.banner { position: relative; max-width: var(--max-width); margin: 16px auto; overflow: hidden; } +.banner-slides { position: relative; height: 420px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 12px 20px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, .6)); +} +.banner-dots { + position: absolute; + bottom: 12px; right: 20px; + display: flex; gap: 8px; +} +.banner-dots button { + width: 10px; height: 10px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .6); + cursor: pointer; +} +.banner-dots button.active { background: #fff; } + +/* ===== 通用区块 ===== */ +.top-strip, .category-block { + max-width: var(--max-width); + margin: 16px auto; + padding: 20px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.section-title, .block-head h2 { + margin: 0 0 12px; + font-size: 20px; + border-left: 4px solid var(--primary); + padding-left: 10px; +} +.block-head { display: flex; align-items: baseline; justify-content: space-between; } +.more { font-size: 13px; color: var(--text-light); } +.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; } +.top-strip li, .block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px dashed var(--border); +} +.top-strip li:last-child, .block-list li:last-child { border-bottom: none; } +.top-strip li a, .block-list li a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-badge { + flex: none; + color: #fff; + background: var(--primary); + font-size: 12px; + padding: 1px 6px; + border-radius: 2px; +} + +/* ===== 列表页 ===== */ +.list-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 24px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 16px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; } +.page-title { + font-size: 22px; + border-left: 4px solid var(--primary); + padding-left: 10px; + margin: 0 0 18px; +} +.article-list { list-style: none; margin: 0; padding: 0; } +.article-list li { padding: 14px 0; border-bottom: 1px dashed var(--border); } +.item-title { font-size: 17px; } +.item-summary { + color: var(--text-light); + font-size: 14px; + margin: 6px 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.item-meta { display: flex; gap: 10px; align-items: center; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: 6px; justify-content: center; padding: 24px 0 4px; } +.pagination a { + min-width: 34px; + text-align: center; + padding: 6px 10px; + border: 1px solid var(--border); + background: var(--card-bg); +} +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情/单页 ===== */ +.detail-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.article-title { margin: 8px 0 12px; font-size: 26px; text-align: center; } +.article-meta { + display: flex; + gap: 18px; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.article-content { line-height: 1.9; font-size: 16px; padding-top: 20px; word-break: break-word; } +.article-content img { height: auto; } +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; } +.article-sibling { display: flex; flex-direction: column; gap: 8px; margin-top: 28px; font-size: 14px; } + +/* ===== 页脚 ===== */ +.site-footer { margin-top: 32px; background: var(--primary-dark); color: rgba(255, 255, 255, .85); } +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; } +.group-name { font-weight: 600; margin-right: 4px; } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 12px 16px 20px; + text-align: center; + font-size: 13px; +} +.footer-info p { margin: 4px 0; } + +/* ===== 移动端适配 ===== */ +@media (max-width: 768px) { + .header-inner { flex-direction: column; align-items: stretch; } + .site-search input { flex: 1; width: auto; } + .site-colleges { align-self: flex-start; } + .site-colleges .colleges-menu { left: 0; right: auto; min-width: 100%; } + .banner-slides { height: 200px; } + .detail-main, .list-main { padding: 18px 14px; } + .article-title { font-size: 22px; } + /* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: transparent; + min-width: 0; + padding: 0 0 6px; + } + .site-nav .sub-nav li a { color: rgba(255, 255, 255, .9); padding: 8px 16px 8px 34px; } + .site-nav .sub-nav li a:hover { background: var(--primary-dark); color: #fff; } +} diff --git a/src/test/resources/templates/themes/t-demo-red/assets/screenshot.png b/src/test/resources/templates/themes/t-demo-red/assets/screenshot.png new file mode 100644 index 0000000..889b91e Binary files /dev/null and b/src/test/resources/templates/themes/t-demo-red/assets/screenshot.png differ diff --git a/src/test/resources/templates/themes/t-demo-red/detail.html b/src/test/resources/templates/themes/t-demo-red/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/test/resources/templates/themes/t-demo-red/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/test/resources/templates/themes/t-demo-red/fragments/footer.html b/src/test/resources/templates/themes/t-demo-red/fragments/footer.html new file mode 100644 index 0000000..5c8e713 --- /dev/null +++ b/src/test/resources/templates/themes/t-demo-red/fragments/footer.html @@ -0,0 +1,18 @@ + + + +
+ + +
+ diff --git a/src/test/resources/templates/themes/t-demo-red/fragments/head.html b/src/test/resources/templates/themes/t-demo-red/fragments/head.html new file mode 100644 index 0000000..6db6d1a --- /dev/null +++ b/src/test/resources/templates/themes/t-demo-red/fragments/head.html @@ -0,0 +1,16 @@ + + + + + + + 页面标题 + + + + + + + + diff --git a/src/test/resources/templates/themes/t-demo-red/fragments/header.html b/src/test/resources/templates/themes/t-demo-red/fragments/header.html new file mode 100644 index 0000000..a79a173 --- /dev/null +++ b/src/test/resources/templates/themes/t-demo-red/fragments/header.html @@ -0,0 +1,53 @@ + + + + + diff --git a/src/test/resources/templates/themes/t-demo-red/home.html b/src/test/resources/templates/themes/t-demo-red/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/test/resources/templates/themes/t-demo-red/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-demo-red/list.html b/src/test/resources/templates/themes/t-demo-red/list.html new file mode 100644 index 0000000..fec0f2c --- /dev/null +++ b/src/test/resources/templates/themes/t-demo-red/list.html @@ -0,0 +1,50 @@ + + + + +
+ +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-demo-red/single.html b/src/test/resources/templates/themes/t-demo-red/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/test/resources/templates/themes/t-demo-red/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/test/resources/templates/themes/t-demo-red/theme.json b/src/test/resources/templates/themes/t-demo-red/theme.json new file mode 100644 index 0000000..6dee881 --- /dev/null +++ b/src/test/resources/templates/themes/t-demo-red/theme.json @@ -0,0 +1,15 @@ +{ + "specVersion": 1, + "name": "t-demo-red", + "title": "测试红(夹具)", + "version": "1.0.0", + "author": "yunhai-test", + "description": "测试夹具:拷贝 default 改主色为深红(方案 §8.7),用于证明多主题机制与白名单。", + "screenshot": "assets/screenshot.png", + "bannerSize": "1920x560", + "params": [ + { "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#b01a2e" }, + { "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#f7f5f2" }, + { "key": "heroImage", "label": "头图(image 类型夹具,B-7/T-6)", "type": "image", "default": "/cms/upload/cms/t-demo-red/hero.png" } + ] +} diff --git a/src/test/resources/templates/themes/t-mismatch/assets/css/main.css b/src/test/resources/templates/themes/t-mismatch/assets/css/main.css new file mode 100644 index 0000000..3a9b9fd --- /dev/null +++ b/src/test/resources/templates/themes/t-mismatch/assets/css/main.css @@ -0,0 +1,324 @@ +/* ============================================================ + * default 皮肤包样式(方案 §8.2) + * 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪,D2 决策点确认)。 + * ============================================================ */ +:root { + --primary: #1a5cb0; + --primary-dark: #12447f; + --text: #333; + --text-light: #777; + --bg: #f5f6f8; + --card-bg: #fff; + --border: #e5e6eb; + --max-width: 1200px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} +a { color: inherit; text-decoration: none; } +a:hover { color: var(--primary); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { color: var(--text-light); text-align: center; padding: 32px 0; } + +/* ===== 头部 ===== */ +.site-header { background: var(--card-bg); border-bottom: 1px solid var(--border); } +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.site-brand { display: flex; align-items: center; gap: 12px; } +.site-logo { height: 44px; } +.site-name { font-size: 26px; font-weight: 700; color: var(--primary); } +.site-search { display: flex; } +.site-search input { + border: 1px solid var(--border); + border-right: none; + padding: 8px 12px; + width: 220px; + outline: none; +} +.site-search button { + border: none; + background: var(--primary); + color: #fff; + padding: 8px 18px; + cursor: pointer; +} + +/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */ +/* 悬停展开白底纵向列表,右对齐 + 超高滚动(院系数十个子站场景) */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + padding: 8px 14px; + cursor: pointer; + color: var(--primary); + font-weight: 600; + white-space: nowrap; +} +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 110; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: 9px 16px; + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: #f2f6fc; color: var(--primary); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } +.site-nav { background: var(--primary); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + flex-wrap: wrap; +} +.site-nav li a { + display: block; + padding: 12px 22px; + color: #fff; +} +.site-nav li:hover a, .site-nav li.active a { background: var(--primary-dark); } + +/* ===== 二级下拉导航 ===== */ +/* 面板为白底纵向列表:需整体覆盖 .site-nav ul 的横向 flex 与内边距 */ +.site-nav > ul > li { position: relative; } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .85; } +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 168px; + max-width: none; + margin: 0; + padding: 6px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-top: 2px solid var(--primary); + box-shadow: 0 6px 16px rgba(0, 0, 0, .12); + z-index: 100; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: 9px 16px; + color: var(--text); + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a:hover { background: #f2f6fc; color: var(--primary); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播 ===== */ +.banner { position: relative; max-width: var(--max-width); margin: 16px auto; overflow: hidden; } +.banner-slides { position: relative; height: 420px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 12px 20px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, .6)); +} +.banner-dots { + position: absolute; + bottom: 12px; right: 20px; + display: flex; gap: 8px; +} +.banner-dots button { + width: 10px; height: 10px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .6); + cursor: pointer; +} +.banner-dots button.active { background: #fff; } + +/* ===== 通用区块 ===== */ +.top-strip, .category-block { + max-width: var(--max-width); + margin: 16px auto; + padding: 20px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.section-title, .block-head h2 { + margin: 0 0 12px; + font-size: 20px; + border-left: 4px solid var(--primary); + padding-left: 10px; +} +.block-head { display: flex; align-items: baseline; justify-content: space-between; } +.more { font-size: 13px; color: var(--text-light); } +.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; } +.top-strip li, .block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px dashed var(--border); +} +.top-strip li:last-child, .block-list li:last-child { border-bottom: none; } +.top-strip li a, .block-list li a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-badge { + flex: none; + color: #fff; + background: var(--primary); + font-size: 12px; + padding: 1px 6px; + border-radius: 2px; +} + +/* ===== 列表页 ===== */ +.list-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 24px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 16px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; } +.page-title { + font-size: 22px; + border-left: 4px solid var(--primary); + padding-left: 10px; + margin: 0 0 18px; +} +.article-list { list-style: none; margin: 0; padding: 0; } +.article-list li { padding: 14px 0; border-bottom: 1px dashed var(--border); } +.item-title { font-size: 17px; } +.item-summary { + color: var(--text-light); + font-size: 14px; + margin: 6px 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.item-meta { display: flex; gap: 10px; align-items: center; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: 6px; justify-content: center; padding: 24px 0 4px; } +.pagination a { + min-width: 34px; + text-align: center; + padding: 6px 10px; + border: 1px solid var(--border); + background: var(--card-bg); +} +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情/单页 ===== */ +.detail-main { + max-width: var(--max-width); + margin: 16px auto; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border); +} +.article-title { margin: 8px 0 12px; font-size: 26px; text-align: center; } +.article-meta { + display: flex; + gap: 18px; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.article-content { line-height: 1.9; font-size: 16px; padding-top: 20px; word-break: break-word; } +.article-content img { height: auto; } +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; } +.article-sibling { display: flex; flex-direction: column; gap: 8px; margin-top: 28px; font-size: 14px; } + +/* ===== 页脚 ===== */ +.site-footer { margin-top: 32px; background: var(--primary-dark); color: rgba(255, 255, 255, .85); } +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; } +.group-name { font-weight: 600; margin-right: 4px; } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 12px 16px 20px; + text-align: center; + font-size: 13px; +} +.footer-info p { margin: 4px 0; } + +/* ===== 移动端适配 ===== */ +@media (max-width: 768px) { + .header-inner { flex-direction: column; align-items: stretch; } + .site-search input { flex: 1; width: auto; } + .site-colleges { align-self: flex-start; } + .site-colleges .colleges-menu { left: 0; right: auto; min-width: 100%; } + .banner-slides { height: 200px; } + .detail-main, .list-main { padding: 18px 14px; } + .article-title { font-size: 22px; } + /* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: transparent; + min-width: 0; + padding: 0 0 6px; + } + .site-nav .sub-nav li a { color: rgba(255, 255, 255, .9); padding: 8px 16px 8px 34px; } + .site-nav .sub-nav li a:hover { background: var(--primary-dark); color: #fff; } +} diff --git a/src/test/resources/templates/themes/t-mismatch/assets/screenshot.png b/src/test/resources/templates/themes/t-mismatch/assets/screenshot.png new file mode 100644 index 0000000..889b91e Binary files /dev/null and b/src/test/resources/templates/themes/t-mismatch/assets/screenshot.png differ diff --git a/src/test/resources/templates/themes/t-mismatch/detail.html b/src/test/resources/templates/themes/t-mismatch/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/test/resources/templates/themes/t-mismatch/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/test/resources/templates/themes/t-mismatch/fragments/footer.html b/src/test/resources/templates/themes/t-mismatch/fragments/footer.html new file mode 100644 index 0000000..5c8e713 --- /dev/null +++ b/src/test/resources/templates/themes/t-mismatch/fragments/footer.html @@ -0,0 +1,18 @@ + + + +
+ + +
+ diff --git a/src/test/resources/templates/themes/t-mismatch/fragments/head.html b/src/test/resources/templates/themes/t-mismatch/fragments/head.html new file mode 100644 index 0000000..a6bcf87 --- /dev/null +++ b/src/test/resources/templates/themes/t-mismatch/fragments/head.html @@ -0,0 +1,14 @@ + + + + + + + 页面标题 + + + + + + diff --git a/src/test/resources/templates/themes/t-mismatch/fragments/header.html b/src/test/resources/templates/themes/t-mismatch/fragments/header.html new file mode 100644 index 0000000..a79a173 --- /dev/null +++ b/src/test/resources/templates/themes/t-mismatch/fragments/header.html @@ -0,0 +1,53 @@ + + + + + diff --git a/src/test/resources/templates/themes/t-mismatch/home.html b/src/test/resources/templates/themes/t-mismatch/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/test/resources/templates/themes/t-mismatch/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-mismatch/list.html b/src/test/resources/templates/themes/t-mismatch/list.html new file mode 100644 index 0000000..fec0f2c --- /dev/null +++ b/src/test/resources/templates/themes/t-mismatch/list.html @@ -0,0 +1,50 @@ + + + + +
+ +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-mismatch/single.html b/src/test/resources/templates/themes/t-mismatch/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/test/resources/templates/themes/t-mismatch/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/test/resources/templates/themes/t-mismatch/theme.json b/src/test/resources/templates/themes/t-mismatch/theme.json new file mode 100644 index 0000000..7690d55 --- /dev/null +++ b/src/test/resources/templates/themes/t-mismatch/theme.json @@ -0,0 +1,8 @@ +{ + "specVersion": 1, + "name": "t-mismatch-x", + "title": "清单名与目录不一致(夹具)", + "version": "1.0.0", + "screenshot": "assets/screenshot.png", + "params": [] +} diff --git a/src/test/resources/templates/themes/t-missing-list/detail.html b/src/test/resources/templates/themes/t-missing-list/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/test/resources/templates/themes/t-missing-list/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/test/resources/templates/themes/t-missing-list/home.html b/src/test/resources/templates/themes/t-missing-list/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/test/resources/templates/themes/t-missing-list/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/test/resources/templates/themes/t-missing-list/single.html b/src/test/resources/templates/themes/t-missing-list/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/test/resources/templates/themes/t-missing-list/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ + + + diff --git a/src/test/resources/templates/themes/t-missing-list/theme.json b/src/test/resources/templates/themes/t-missing-list/theme.json new file mode 100644 index 0000000..d28545a --- /dev/null +++ b/src/test/resources/templates/themes/t-missing-list/theme.json @@ -0,0 +1,7 @@ +{ + "specVersion": 1, + "name": "t-missing-list", + "title": "缺列表模板(夹具)", + "version": "1.0.0", + "params": [] +}