Files
YschcmsService/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.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

283 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.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();
}
// ===== forPreview 预览资产前缀(W3:context-path 修复,方案 §7.3)=====
@Test
@DisplayName("T-W3-01: forPreview 带上下文:资产前缀含 contextPath")
void forPreview_assetPrefixCarriesContextPath() {
PortalUrlHelper url = PortalUrlHelper.forPreview(
request("http", "base.yunhaixingtong.com", 80, "/yschcms"), "demo", "scholar-blue");
assertThat(url.asset("css/main.css"))
.isEqualTo("/yschcms/api/theme/scholar-blue/asset/css/main.css");
}
@Test
@DisplayName("T-W3-02: forPreview 空上下文退化:前缀 /api/theme/…(dev 形态不回归)")
void forPreview_emptyContextFallsBackToRootPrefix() {
PortalUrlHelper url = PortalUrlHelper.forPreview(
request("http", "localhost", 8072, ""), "demo", "t-demo-red");
assertThat(url.asset("css/main.css"))
.isEqualTo("/api/theme/t-demo-red/asset/css/main.css");
}
@Test
@DisplayName("T-W3-03: forPreview 站内链接不受资产前缀串味(home/category 仍指真实门户)")
void forPreview_homeStaysRealPortal() {
PortalUrlHelper url = PortalUrlHelper.forPreview(
request("http", "base.yunhaixingtong.com", 80, "/yschcms"), "demo", "scholar-blue");
assertThat(url.home()).isEqualTo("http://base.yunhaixingtong.com/yschcms/portal/demo/");
assertThat(url.category(3)).isEqualTo("http://base.yunhaixingtong.com/yschcms/portal/demo/c/3");
assertThat(url.search("x", null))
.isEqualTo("http://base.yunhaixingtong.com/yschcms/portal/demo/search?q=x");
}
// ===== 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");
}
// ===== 服务中心(事项库 M0,详细设计 §5.4:模板不拼 query,URL 由后端预生成)=====
@Test
@DisplayName("serviceList/serviceItem:基址与指南页路径拼接")
void serviceList_andServiceItem_paths() {
PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn", "demo", "default");
assertThat(url.serviceList()).isEqualTo("https://www.xxx.edu.cn/service");
assertThat(url.serviceItem(9)).isEqualTo("https://www.xxx.edu.cn/sv/9");
// 嵌套 sitePath 形态同既有路由
PortalUrlHelper nested = new PortalUrlHelper(
"https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy", "sch-3/jdxy", "default");
assertThat(nested.serviceList()).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/service");
assertThat(nested.serviceItem(12)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/sv/12");
}
@Test
@DisplayName("serviceList(Map):null/空串值跳过、中文 URL 编码、& 连接")
void serviceList_mapSkipsBlankAndEncodes() throws Exception {
PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn", "demo", "default");
java.util.Map<String, Object> query = new java.util.LinkedHashMap<>();
query.put("obj", "个人");
query.put("cat", null);
query.put("dept", "");
query.put("q", "转 学");
query.put("page", 2);
assertThat(url.serviceList(query)).isEqualTo(
"https://www.xxx.edu.cn/service?obj=" + java.net.URLEncoder.encode("个人", "UTF-8")
+ "&q=" + java.net.URLEncoder.encode("转 学", "UTF-8") + "&page=2");
// 全空值 = 纯基址(不带尾问号)
java.util.Map<String, Object> blanks = new java.util.LinkedHashMap<>();
blanks.put("obj", null);
blanks.put("q", "");
assertThat(url.serviceList(blanks)).isEqualTo("https://www.xxx.edu.cn/service");
assertThat(url.serviceList(null)).isEqualTo("https://www.xxx.edu.cn/service");
assertThat(url.serviceList(new java.util.HashMap<>())).isEqualTo("https://www.xxx.edu.cn/service");
}
// ===== W4 嵌套路径(方案 §8.3 T-W4-11/12:构造参语义 code→sitePath)=====
@Test
@DisplayName("T-W4-11: 嵌套 sitePath 的 baseUrl/asset/category 全嵌套")
void nestedSitePath_allUrlsNested() {
PortalUrlHelper url = PortalUrlHelper.fromBaseOrigin(
"https://base.yunhaixingtong.com/cms", "sch-3/jdxy", "scholar-blue");
assertThat(url.home()).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/");
assertThat(url.category(7)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/c/7");
assertThat(url.article(9)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/a/9");
assertThat(url.asset("css/main.css"))
.isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/assets/scholar-blue/css/main.css");
}
@Test
@DisplayName("T-W4-12: siteHome 兄弟/父级——当前=sch-3/jdxy 时替换站点段为嵌套目标")
void nestedSitePath_siteHomeSiblingsAndParent() {
PortalUrlHelper url = new PortalUrlHelper(
"https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy", "sch-3/jdxy", "default",
"https://base.yunhaixingtong.com/cms");
// 父级主站
assertThat(url.siteHome("sch-3", null)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/");
// 兄弟子站(嵌套形态同构)
assertThat(url.siteHome("sch-3/other", null))
.isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/other/");
// 目标配 public_base_url 时优先
assertThat(url.siteHome("sch-3/other", "https://other.xxx.edu.cn")).isEqualTo("https://other.xxx.edu.cn/");
}
}