Files
YschcmsService/src/test/java/com/yunhai/cms/svcitem/SvcItemPortalIT.java
T
chenxing 9e28092a8b
build / build-push (push) Successful in 2m26s
feat(svcitem): 事项库+办事大厅 R2 后端落地,并完成交付后独立审计修复
落地(依据《事项库与办事大厅详细设计 v1》)
- 事项域垂直包 com.yunhai.cms.svcitem.*(entity/enums/repository/service+impl/event/config/model dto+query+vo)
  与 controller/{api,oapi}/svcitem;两表 DDL 进 cms_init.sql(表 7/8)+ upgrade_svcitem_r2.sql
- SvcItemQuery 接缝:门户 8 方法 + 管理端 11 方法统一收口,控制器不再注入实现类
- 门户公开层:doServiceList/doGuide 双路由变体 + M0 可选模板整页回退
  (视图名/themeBase/buildUrl/资产主题段四处同源 effectiveTheme,缺模板整包退 default)
- oapi 跨服务快照:与平台 AppAuthFilter 同口径签名(canonical=method\npath?sortedQuery\ntimestamp,
  HmacSHA256/Base64, 5min 窗口)+ /oapi/* 强制验签过滤器
- default 皮肤 service.html/guide.html + main.css 服务中心/指南页样式段

审计修复(发现与处置全表见 docs/18-事项库审计与修复记录.md)
- D1 附件引用链:内容资产(封面+正文内嵌图)独立场景 svc_item_asset,保存时按内容派生全量同步归属
  (AttachmentService.localAssetUrls/localWebUrl/syncAssets:集合内标记、集合外解除)。
  修复根因:upload 带 scene 时只写 ref_type 不写 ref_id,而保存仅按前端提交 id 重绑,
  正文图与封面停在半绑定态 → 既不在孤儿口径内也不受 release 管辖,成永久无主行
- D5 归属校验:绑定前做可认领判定(无引用/半绑定/已属本事项方可绑),他事项引用与跨站附件跳过并告警
- D2 部门:组类型改配置 cms.svc.item.dept-group-type(默认 2=学校组,校内科室改 3);
  deptTree 返回嵌套组树;新增 SvcDeptSource 接缝使静态 getGroupTree 可替身(此前部门链零覆盖)
- D4 缓存:事件 scope 语义化,dict/dept 变更整体失效 cms:svcItem(展示名快照不再滞后 5min)
- 站点解析:findByCode 在 uk(parent_site_id,code) 下命中多条会抛异常变 500,
  4 处生产调用点改 findFirstByCodeOrderByIdAsc(确定性取最早站点)
- 加固:新增 idx_svc_item_portal(含 DESC 列)对症门户排序 filesort;升级脚本改 information_schema
  +PREPARE 幂等索引守卫块(表在缺索引可重跑修复);clear_cms_data.sql 补事项两表;
  view_count 改有界队列+拒绝丢弃告警;SEL_LIST_MAX 截断告警

测试
- 新增 SvcDeptChainIT 7、SvcItemServiceIT 16(含 D1/D5 回归锚点与全方法权限)、
  SvcItemOapiIT 9(时间戳超窗/未知 appKey/畸形头/siteCode 歧义)、SvcItemPortalIT 11
  (嵌套指南路由/关键词/canonical/浏览量异步)、SvcDictServiceIT 8(改名后缓存即时刷新)
- ThemeContractIT 补"缺可选模板主题资产段必退 default"断言并订正七路由表述
- 全量 mvn -o test:388 测试 0 失败;升级脚本 bsch_cms_test 两跑幂等(含缺索引自动补建)
2026-09-11 14:12:04 +08:00

300 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.cms.svcitem;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum;
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;
/**
* 门户服务中心/事项指南集成测试(事项库详细设计 §5/§6 + M0 回退 IT 0.3):
* 路由双变体、301 兼容、404 语义、筛选与分页 URL 预生成(C-2)、富文本补基准、
* applyUrl 三态、可选模板整页回退(缺模板主题的资产段退 default)。
*/
@DisplayName("SvcItemPortal - 门户公开层(L2)")
class SvcItemPortalIT extends CmsIntegrationTestBase {
@Autowired
private MockMvc mockMvc;
@Autowired
private com.yunhai.cms.svcitem.service.SvcItemQuery svcItemQuery;
private Integer siteId;
@BeforeEach
void seed() {
purgeTables();
siteId = persistSite("demo1", 1, "演示站点一").getId();
persistSvcDict(siteId, "object", "person", "个人", 1);
persistSvcDict(siteId, "object", "legal", "法人", 1);
persistSvcDict(siteId, "category", "jw", "教务服务", 1);
}
@AfterEach
void clean() {
purgeTables();
}
private Integer publishItem(String code, String title, String objectKey, String categoryKey) {
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
persistSvcItem(siteId, code, title, ServiceItemStatusEnum.PUBLISHED);
item.setObjectKey(objectKey);
item.setCategoryKey(categoryKey);
item.setDeptName("教务处");
return serviceItemRepository.saveAndFlush(item).getId();
}
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
}
// ===== 路由(§5.1:单段 + 嵌套双变体、301、404)=====
@Test
@DisplayName("路由双变体:/portal/{code}/service 与 /portal/{p}/{s}/service 均 200")
void routes_singleAndNested() throws Exception {
publishItem("S1", "转学办理", "person", "jw");
assertThat(body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn()))
.contains("服务中心").contains("转学办理");
Integer parent = persistSite("schx", 1, "主站").getId();
Integer childId = persistSite("child", 1, "子站", parent).getId();
com.yunhai.cms.svcitem.entity.CmsServiceItem child =
persistSvcItem(childId, "C1", "子站事项", ServiceItemStatusEnum.PUBLISHED);
serviceItemRepository.saveAndFlush(child);
assertThat(body(mockMvc.perform(get("/portal/schx/child/service"))
.andExpect(status().isOk()).andReturn()))
.contains("服务中心").contains("子站事项");
}
@Test
@DisplayName("旧单段 code 访问子站服务页 301 到嵌套形态(保留子路径与 query)")
void routes_legacySingleSegmentRedirects301() throws Exception {
Integer parent = persistSite("schy", 1, "主站Y").getId();
persistSite("childy", 1, "子站Y", parent);
// query 串需走 URI 模板直传(MockMvc param() 不进 getQueryString(),真实请求无此差异)
mockMvc.perform(get("/portal/childy/service?obj=person"))
.andExpect(status().isMovedPermanently())
.andExpect(header().string("Location", "/portal/schy/childy/service?obj=person"));
}
@Test
@DisplayName("指南页路由:存在且已发布 200;不存在/草稿 404;响应头带 Cache-Control")
void guideItem_statusSemantics() throws Exception {
Integer id = publishItem("S1", "转学办理", "person", "jw");
mockMvc.perform(get("/portal/demo1/sv/{id}", id))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "max-age=300"));
mockMvc.perform(get("/portal/demo1/sv/{id}", 999999))
.andExpect(status().isNotFound());
Integer draft = persistSvcItem(siteId, "S2", "草稿事项", ServiceItemStatusEnum.DRAFT).getId();
mockMvc.perform(get("/portal/demo1/sv/{id}", draft))
.andExpect(status().isNotFound());
}
// ===== M0 整页回退(§4.2/§4.3:缺可选模板的主题整页退 default,资产段同步退)=====
@Test
@DisplayName("M0: 无 service.html 的主题访问服务中心 → 200 且资产 URL 主题段为 default(防 default HTML 配他家 CSS)")
void fallback_themeWithoutServiceTemplateFallsBackWholePage() throws Exception {
publishItem("S1", "转学办理", "person", "jw");
// t-demo-red 为测试夹具包:注册成功(必备四页齐全)但无 service.html/guide.html
com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new);
site.setTheme("t-demo-red");
siteRepository.saveAndFlush(site);
clearCaches();
String html = body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn());
assertThat(html).contains("服务中心").contains("转学办理");
assertThat(html).contains("/assets/default/css/main.css");
assertThat(html).doesNotContain("/assets/t-demo-red/");
Integer id = serviceItemRepository.findBySiteIdAndItemCode(siteId, "S1")
.orElseThrow(IllegalStateException::new).getId();
String guideHtml = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id))
.andExpect(status().isOk()).andReturn());
assertThat(guideHtml).contains("/assets/default/css/main.css");
assertThat(guideHtml).doesNotContain("/assets/t-demo-red/");
}
// ===== 列表筛选与分页 URL 预生成(C-2,§5.4)=====
@Test
@DisplayName("三维筛选:计数正确、facet URL 后端预生成(勾选带参/取消去参),模板零拼 query")
void serviceList_filtersAndFacetUrls() throws Exception {
publishItem("S1", "转学办理", "person", "jw");
publishItem("S2", "休学办理", "person", "jw");
publishItem("S3", "法人年检", "legal", "jw");
String html = body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn());
assertThat(html).contains("个人").contains("法人").contains("教务服务");
// 未勾选态 facet URL:带本维参数(page 归 1,无 page 参数)
assertThat(html).contains("/portal/demo1/service?obj=person\">");
assertThat(html).contains("/portal/demo1/service?obj=legal\">");
// 勾选 obj=person 后:仅个人事项;取消链接回到无参基址;cat facet URL 保留 obj 维
String filtered = body(mockMvc.perform(get("/portal/demo1/service")
.param("obj", "person"))
.andExpect(status().isOk()).andReturn());
assertThat(filtered).contains("转学办理").contains("休学办理");
assertThat(filtered).doesNotContain("法人年检");
assertThat(filtered).contains("/portal/demo1/service\">");
assertThat(filtered).contains("/portal/demo1/service?obj=person&cat=jw\">");
assertThat(filtered).contains("清除筛选");
}
@Test
@DisplayName("分页 URL 预生成:携带当前筛选与关键词(编码),第 2 页起带 page 参数")
void serviceList_paginationUrlsCarryFilters() throws Exception {
for (int i = 1; i <= 12; i++) {
publishItem("S" + i, "事项" + String.format("%02d", i), "person", "jw");
}
String html = body(mockMvc.perform(get("/portal/demo1/service")
.param("obj", "person").param("q", "事项"))
.andExpect(status().isOk()).andReturn());
// 第 2 页链接保留 obj + 编码后的 q,并追加 page=2(链接组装顺序 = obj, q, page)
assertThat(html).contains("obj=person&amp;q=%E4%BA%8B%E9%A1%B9&amp;page=2");
// 第 1 页(active)不带 page 参数
assertThat(html).doesNotContain("page=1\"");
}
@Test
@DisplayName("空结果与空站点:优雅空态不 500")
void serviceList_emptyState() throws Exception {
String html = body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn());
assertThat(html).contains("暂未上架");
}
@Test
@DisplayName("关键词命中标题与摘要;canonical 预生成;嵌套指南页路由可用")
void serviceList_keywordCanonicalAndNestedGuide() throws Exception {
Integer id = publishItem("S1", "转学办理", "person", "jw");
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new);
item.setSummary("适用于在校学生转学申请");
serviceItemRepository.saveAndFlush(item);
publishItem("S2", "休学办理", "person", "jw");
String html = body(mockMvc.perform(get("/portal/demo1/service").param("q", "在校学生"))
.andExpect(status().isOk()).andReturn());
assertThat(html).as("关键词命中摘要").contains("转学办理").doesNotContain("休学办理");
assertThat(html).contains("rel=\"canonical\"").contains("/portal/demo1/service");
// 嵌套指南页路由(设计 §5.1 成对路由:此前只测了嵌套列表页)
Integer parent = persistSite("schg", 1, "主站G").getId();
Integer childId = persistSite("childg", 1, "子站G", parent).getId();
com.yunhai.cms.svcitem.entity.CmsServiceItem childItem =
persistSvcItem(childId, "C1", "子站事项", ServiceItemStatusEnum.PUBLISHED);
childItem.setProcessHtml("<p>子站流程</p>");
serviceItemRepository.saveAndFlush(childItem);
assertThat(body(mockMvc.perform(get("/portal/schg/childg/sv/{id}", childItem.getId()))
.andExpect(status().isOk()).andReturn()))
.contains("子站事项").contains("子站流程").contains("rel=\"canonical\"");
}
@Test
@DisplayName("指南页浏览量异步累加:HTTP 访问触发 + 计数器原子自增(不承诺即时,轮询等待)")
void guideItem_viewCountIncrementsAsync() throws Exception {
Integer id = publishItem("S1", "转学办理", "person", "jw");
// ① HTTP 路径:指南页渲染成功并触发一次计数(异步)
mockMvc.perform(get("/portal/demo1/sv/{id}", id)).andExpect(status().isOk());
// ② 再补 4 次显式计数:把"异步累加链路是否工作"与"MockMvc 渲染耗时"解耦
for (int i = 0; i < 4; i++) {
svcItemQuery.countView(id);
}
int expected = 5;
int viewCount = 0;
// 轮询预算 30s:异步任务是 fire-and-forget(设计不承诺即时),且满载环境下取连接可能等待
// Hikari 默认 connectionTimeout(30s),故预算需覆盖该量级,避免把连接竞争误判为功能缺陷
for (int i = 0; i < 300; i++) {
viewCount = serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new)
.getViewCount();
if (viewCount >= expected) {
break;
}
java.lang.Thread.sleep(100);
}
assertThat(viewCount).as("1 次指南页访问 + 4 次显式计数应累加为 %s", expected)
.isGreaterThanOrEqualTo(expected);
}
// ===== 指南页装配(§5.3)=====
@Test
@DisplayName("指南页:富文本补上传基准、附件下载、关联栏目 url.category、关联事项、手填外链按钮")
void guide_modelAssembly() throws Exception {
Integer faqCat = persistCategory(siteId, 0, "FAQ", com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId();
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
serviceItemRepository.findById(publishItem("S1", "转学办理", "person", "jw")).orElseThrow(IllegalStateException::new);
item.setProcessHtml("<p><img src=\"/cms/upload/cms/demo1/202609/a.jpg\"></p>");
item.setFaqCategoryId(faqCat);
item.setExtJson("{\"apply_url\":\"https://apply.example.com/zx\"}");
serviceItemRepository.saveAndFlush(item);
com.yunhai.cms.entity.CmsAttachment att = persistAttachment(siteId, "svc_item", "申请表.pdf");
att.setRefId(item.getId());
attachmentRepository.saveAndFlush(att);
publishItem("S2", "休学办理", "person", "jw"); // 同类别关联事项
String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", item.getId()))
.andExpect(status().isOk()).andReturn());
// 上传地址渲染期补基准(B-1 口径:MockMvc 请求派生基准 = http://localhost)
assertThat(html).contains("src=\"http://localhost/cms/upload/cms/demo1/202609/a.jpg\"");
// 附件与下载链接(webUrl 相对路径经 url.upload 补基准)
assertThat(html).contains("申请表.pdf");
assertThat(html).contains("http://localhost/cms/upload/cms/" + siteId + "/");
// 关联栏目链接经 url.category 生成
assertThat(html).contains("/portal/demo1/c/" + faqCat);
// 关联事项(同类别已发布,排除自身)
assertThat(html).contains("休学办理");
// 我要办理三态①:手填外链优先
assertThat(html).contains("https://apply.example.com/zx");
assertThat(html).contains("我要办理");
}
@Test
@DisplayName("我要办理三态:② svc_hall_base 生成 svhall 发起页 URL;③ 都没有 → 不渲染按钮")
void applyUrl_generatedAndAbsentStates() throws Exception {
// ② 未手填且站点配 svc_hall_base → 生成(尾斜杠归一)
com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new);
site.setConfigJson("{\"svc_hall_base\":\"https://svhall.example.com/\"}");
siteRepository.saveAndFlush(site);
clearCaches();
Integer id = publishItem("S1", "转学办理", "person", "jw");
String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id))
.andExpect(status().isOk()).andReturn());
assertThat(html).contains("我要办理");
assertThat(html).contains("https://svhall.example.com/#/apply?siteCode=demo1&amp;itemCode=S1");
// ③ 都没有 → 按钮不渲染
com.yunhai.cms.entity.CmsSite bare = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new);
bare.setConfigJson(null);
siteRepository.saveAndFlush(bare);
clearCaches();
Integer id2 = publishItem("S2", "休学办理", "person", "jw");
String html2 = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id2))
.andExpect(status().isOk()).andReturn());
// 以按钮类名断言(模板源码注释含"我要办理"字样会随注释输出)
assertThat(html2).doesNotContain("svc-apply-btn");
assertThat(html2).doesNotContain("svhall.example.com");
}
}