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 两跑幂等(含缺索引自动补建)
283 lines
16 KiB
Java
283 lines
16 KiB
Java
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/");
|
||
}
|
||
}
|