init local code

This commit is contained in:
chenxing
2026-09-07 09:09:39 +08:00
commit 99cd26b3d2
259 changed files with 20554 additions and 0 deletions
@@ -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()));
}
}
@@ -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);
}
}
@@ -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*<a[^>]*>机电学院");
}
@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/")));
}
}
@@ -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 回归)。
*
* <p>必须起真实端口: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<String> 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<Void>(headers), String.class, uriVars);
}
@Test
@DisplayName("404:不存在的站点渲染 HTML 错误页(非 Spring JSON)")
void notFound_rendersHtmlErrorPage() {
ResponseEntity<String> 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<String> 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<String> 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);
}
}
@@ -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("<p>云海第一中学始建于 1958 年。</p>");
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("<title>");
}
@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());
}
}
@@ -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();
}
}
}
@@ -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");
}
}
@@ -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();
}
}
@@ -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("文章不存在");
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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("无权");
}
}
@@ -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);
}
}
@@ -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("不存在");
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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("未登录");
}
}
@@ -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"));
}
}
}
@@ -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");
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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");
}
}
+94
View File
@@ -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
+134
View File
@@ -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 友情链接';
@@ -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; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -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>
@@ -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>
@@ -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}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<link th:if="${site.config['favicon'] != null}" rel="icon" th:href="${site.config['favicon']}">
</head>
</html>
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部:站点名 + 栏目导航 + 站内搜索 + 专业院系下拉(院系子站切换,《院系子站方案》) -->
<header class="site-header" th:fragment="header">
<div class="header-inner">
<div class="site-brand">
<img th:if="${site.config['logo'] != null}" class="site-logo"
th:src="${site.config['logo']}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="site-search" th:action="${url.search('', null)}" method="get">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
<!-- 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="colleges-arrow">▾</i>
</span>
<ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
</div>
<nav class="site-nav">
<ul>
<li th:classappend="${currentCategoryId == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}" th:text="${cat.name}">栏目</a>
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
<ul class="sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,57 @@
<!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="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!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="list-main">
<nav class="breadcrumb" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</nav>
<nav class="breadcrumb" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span>搜索结果</span>
</nav>
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="article-list">
<li th:each="item : ${listData.items}">
<a class="item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</p>
<div class="item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}">
<a th:if="${cur > 1}"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:text="${p}">1</a>
<a th:if="${cur < total}"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
</nav>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!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>
<span th:text="${category.name}">栏目</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -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" }
]
}
@@ -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; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -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>
@@ -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>
@@ -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}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<link th:if="${site.config['favicon'] != null}" rel="icon" th:href="${site.config['favicon']}">
</head>
</html>
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部:站点名 + 栏目导航 + 站内搜索 + 专业院系下拉(院系子站切换,《院系子站方案》) -->
<header class="site-header" th:fragment="header">
<div class="header-inner">
<div class="site-brand">
<img th:if="${site.config['logo'] != null}" class="site-logo"
th:src="${site.config['logo']}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="site-search" th:action="${url.search('', null)}" method="get">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
<!-- 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="colleges-arrow">▾</i>
</span>
<ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
</div>
<nav class="site-nav">
<ul>
<li th:classappend="${currentCategoryId == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}" th:text="${cat.name}">栏目</a>
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
<ul class="sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,57 @@
<!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="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!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="list-main">
<nav class="breadcrumb" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</nav>
<nav class="breadcrumb" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span>搜索结果</span>
</nav>
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="article-list">
<li th:each="item : ${listData.items}">
<a class="item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</p>
<div class="item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}">
<a th:if="${cur > 1}"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:text="${p}">1</a>
<a th:if="${cur < total}"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
</nav>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!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>
<span th:text="${category.name}">栏目</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -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" }
]
}
@@ -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; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -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>
@@ -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>
@@ -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}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<link th:if="${site.config['favicon'] != null}" rel="icon" th:href="${site.config['favicon']}">
</head>
</html>
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部:站点名 + 栏目导航 + 站内搜索 + 专业院系下拉(院系子站切换,《院系子站方案》) -->
<header class="site-header" th:fragment="header">
<div class="header-inner">
<div class="site-brand">
<img th:if="${site.config['logo'] != null}" class="site-logo"
th:src="${site.config['logo']}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="site-search" th:action="${url.search('', null)}" method="get">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
<!-- 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="colleges-arrow">▾</i>
</span>
<ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
</div>
<nav class="site-nav">
<ul>
<li th:classappend="${currentCategoryId == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}" th:text="${cat.name}">栏目</a>
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
<ul class="sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,57 @@
<!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="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!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="list-main">
<nav class="breadcrumb" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</nav>
<nav class="breadcrumb" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span>搜索结果</span>
</nav>
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="article-list">
<li th:each="item : ${listData.items}">
<a class="item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</p>
<div class="item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}">
<a th:if="${cur > 1}"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:text="${p}">1</a>
<a th:if="${cur < total}"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
</nav>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!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>
<span th:text="${category.name}">栏目</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -0,0 +1,8 @@
{
"specVersion": 1,
"name": "t-bad-shot",
"title": "声明截图缺失(夹具)",
"version": "1.0.0",
"screenshot": "assets/no-such-shot.png",
"params": []
}
@@ -0,0 +1,57 @@
<!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="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1 @@
{ this is not valid json ***
@@ -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; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -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>
@@ -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>
@@ -0,0 +1,16 @@
<!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}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<link th:if="${site.config['favicon'] != null}" rel="icon" th:href="${site.config['favicon']}">
</head>
</html>
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部:站点名 + 栏目导航 + 站内搜索 + 专业院系下拉(院系子站切换,《院系子站方案》) -->
<header class="site-header" th:fragment="header">
<div class="header-inner">
<div class="site-brand">
<img th:if="${site.config['logo'] != null}" class="site-logo"
th:src="${site.config['logo']}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="site-search" th:action="${url.search('', null)}" method="get">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
<!-- 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="colleges-arrow">▾</i>
</span>
<ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
</div>
<nav class="site-nav">
<ul>
<li th:classappend="${currentCategoryId == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}" th:text="${cat.name}">栏目</a>
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
<ul class="sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,57 @@
<!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="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!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="list-main">
<nav class="breadcrumb" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</nav>
<nav class="breadcrumb" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span>搜索结果</span>
</nav>
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="article-list">
<li th:each="item : ${listData.items}">
<a class="item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</p>
<div class="item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}">
<a th:if="${cur > 1}"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:text="${p}">1</a>
<a th:if="${cur < total}"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
</nav>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!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>
<span th:text="${category.name}">栏目</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -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" }
]
}
@@ -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; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -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>
@@ -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>
@@ -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}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<link th:if="${site.config['favicon'] != null}" rel="icon" th:href="${site.config['favicon']}">
</head>
</html>
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部:站点名 + 栏目导航 + 站内搜索 + 专业院系下拉(院系子站切换,《院系子站方案》) -->
<header class="site-header" th:fragment="header">
<div class="header-inner">
<div class="site-brand">
<img th:if="${site.config['logo'] != null}" class="site-logo"
th:src="${site.config['logo']}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="site-search" th:action="${url.search('', null)}" method="get">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
<!-- 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="colleges-arrow">▾</i>
</span>
<ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
</div>
<nav class="site-nav">
<ul>
<li th:classappend="${currentCategoryId == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}" th:text="${cat.name}">栏目</a>
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
<ul class="sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,57 @@
<!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="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!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="list-main">
<nav class="breadcrumb" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</nav>
<nav class="breadcrumb" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span>搜索结果</span>
</nav>
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="article-list">
<li th:each="item : ${listData.items}">
<a class="item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</p>
<div class="item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}">
<a th:if="${cur > 1}"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:text="${p}">1</a>
<a th:if="${cur < total}"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
</nav>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!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>
<span th:text="${category.name}">栏目</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -0,0 +1,8 @@
{
"specVersion": 1,
"name": "t-mismatch-x",
"title": "清单名与目录不一致(夹具)",
"version": "1.0.0",
"screenshot": "assets/screenshot.png",
"params": []
}
@@ -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>
@@ -0,0 +1,57 @@
<!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="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!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>
<span th:text="${category.name}">栏目</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -0,0 +1,7 @@
{
"specVersion": 1,
"name": "t-missing-list",
"title": "缺列表模板(夹具)",
"version": "1.0.0",
"params": []
}