init local code
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}">更多 ></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}">更多 ></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}">更多 ></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}">更多 ></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}">更多 ></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}">更多 ></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}">更多 ></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": []
|
||||
}
|
||||
Reference in New Issue
Block a user