feat(svcitem): 事项库+办事大厅 R2 后端落地,并完成交付后独立审计修复
build / build-push (push) Successful in 2m26s

落地(依据《事项库与办事大厅详细设计 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 两跑幂等(含缺索引自动补建)
This commit is contained in:
chenxing
2026-09-11 14:12:04 +08:00
parent 39454e4efb
commit 9e28092a8b
61 changed files with 5537 additions and 23 deletions
@@ -0,0 +1,299 @@
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");
}
}