feat(msg): JumpTarget 标准化接入——两处跳转换 SDK 结构化门面(app+jump 链式/setBizJump),jumpPath 与 bizUrl 输出逐字节零差异;随车自指地址 Phase2 镜像收口(front-base-url 与 callback-* 键删除、裸相对路径、jumpApp 归属声明,测试同步);application.yml 服务目录键 flowcenter→flow-center 归一(D1 P3 尾巴)
build / build-push (push) Successful in 2m15s

This commit is contained in:
chenxing
2026-09-29 11:00:05 +08:00
parent 082ef0af0a
commit 0216d0655c
7 changed files with 52 additions and 73 deletions
@@ -5,7 +5,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
* 通知-流程中心接缝配置(notice.flow.* / notice.front.*)。 * 通知-流程中心接缝配置(notice.flow.*)。front-base-url 已删(2026-09-26):
* bizUrl 改传裸相对路径,绝对化由接收方按页面归属承担(自指地址优化 Phase 2)。
*/ */
@Data @Data
@Component @Component
@@ -22,28 +23,4 @@ public class NoticeFlowProperties {
* fail-fast,避免静默打 localhost 把"地址没配"伪装成"流程中心不可达"。 * fail-fast,避免静默打 localhost 把"地址没配"伪装成"流程中心不可达"。
*/ */
private String baseUrl = ""; private String baseUrl = "";
/**
* 本应用对外可达基址(回调注册用):flow-center 探活与事件推送都打这里。
* 默认回落 baseUrl 同端口的本服务直连地址;生产经网关时由 projman 注入(如 http://notice:8070/notice)。
*/
private String callbackBase = "http://localhost:8101";
/**
* 回调路径:挂在开放接口域下,由平台应用验签(/oapi/* 强制)保护,不再有独立回调过滤器。
* 取 starter 默认形态 /oapi/flow/callback——网关契约 /{target}/oapi/** 的首段已由 target
* 承担,端点内不再带应用名前缀(2026-09-24 由 /oapi/notice/flow/callback 收敛)。
*/
private String callbackPath = "/oapi/flow/callback";
/**
* 本服务部署 context-path(可选,无则空):注册时随 basePath 一并申报给 flow-center,
* 推送方按此从回调 URL 的 path 中剥离后再签 X-Signature,与平台 AppAuthFilter 用
* request.contextPath 剥离后的 path 对齐。生产 Notice 部署在 /notice 前缀下,须配 /notice;
* 配错的表现是回调 401(推送方签的 target 与本服务验签 target 不一致)。
*/
private String callbackBasePath = "";
/** Notice 前端基址(bizUrl 拼接:{front-base}/#/list/detail?serid={id},审批人点链接回通知详情) */
private String frontBaseUrl = "http://localhost:8077";
} }
@@ -12,6 +12,7 @@ import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.UpFileRepository; import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.auth.spring.CurrentBoaUser; import com.yunhai.platform.auth.spring.CurrentBoaUser;
import com.yunhai.platform.openapi.core.JumpTarget;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -128,15 +129,17 @@ public class NoticeFlowService {
callbackRegistrar.ensureRegistered(notice.getSchId(), notice.getFlowGuid()); callbackRegistrar.ensureRegistered(notice.getSchId(), notice.getFlowGuid());
} }
/** apply 出参组装(契约冻结:bizId=notice.id、bizUrl 前端详情、快照只收摘要不收富文本正文; /** apply 出参组装(契约冻结:bizId=notice.id、bizUrl=裸相对路径、快照只收摘要不收富文本正文;
* formData 带发文部门(2026-09-24)——条件路由判断端点优先读它免查库,缺字段回落查表同口径) */ * formData 带发文部门(2026-09-24)——条件路由判断端点优先读它免查库,缺字段回落查表同口径。
* bizUrl 自 2026-09-26 起为裸相对路径(不含域名/ctx/#,接收方按页面归属拼绝对地址),
* Notice 不再需要配置自己的前端基址(自指地址优化 Phase 2) */
FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) { FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) {
FlowModels.ApplyBody body = new FlowModels.ApplyBody(); FlowModels.ApplyBody body = new FlowModels.ApplyBody();
body.setFlowGuid(notice.getFlowGuid()); body.setFlowGuid(notice.getFlowGuid());
body.setBizType(FlowCenterClient.BIZ_TYPE); body.setBizType(FlowCenterClient.BIZ_TYPE);
body.setBizId(String.valueOf(notice.getId())); body.setBizId(String.valueOf(notice.getId()));
body.setBizTitle(notice.getTitle()); body.setBizTitle(notice.getTitle());
body.setBizUrl(properties.getFrontBaseUrl() + "/#/list/detail?serid=" + notice.getId()); body.setBizJump(JumpTarget.page("list/detail").param("serid", notice.getId()));
body.setBizSnapshot(buildSnapshot(notice)); body.setBizSnapshot(buildSnapshot(notice));
Map<String, Object> formData = new LinkedHashMap<>(); Map<String, Object> formData = new LinkedHashMap<>();
formData.put("deptId", notice.getDeptId()); formData.put("deptId", notice.getDeptId());
@@ -1,6 +1,7 @@
package com.yunhai.notice.service.msg; package com.yunhai.notice.service.msg;
import com.yunhai.notice.entity.Notice; import com.yunhai.notice.entity.Notice;
import com.yunhai.platform.openapi.core.JumpTarget;
import com.yunhai.platform.openapi.msg.model.SendMsgRequest; import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -121,10 +122,11 @@ public class NoticeUserMsgNotifier {
request.setMsgNo(msgNo); request.setMsgNo(msgNo);
request.setBizType(bizType); request.setBizType(bizType);
request.setBizId(String.valueOf(notice.getId())); request.setBizId(String.valueOf(notice.getId()));
// 0系统通知;跳转 Notice 前端详情页(type=1 打开即标读),UserHome 侧按 bizType=notice 基址拼绝对地址 // 0系统通知;跳转 Notice 前端详情页(type=1 打开即标读)。jumpApp=notice 声明页面归属:
// 接收方按它查基址拼绝对地址(bizType=notice.expedite 等细分分类也能命中 notice 应用键)
request.setMsgType(0); request.setMsgType(0);
request.setJumpType(1); request.app("notice")
request.setJumpPath("/list/detail?serid=" + notice.getId() + "&type=1"); .jump(JumpTarget.page("list/detail").param("serid", notice.getId()).param("type", 1));
request.setSchId(notice.getSchId()); request.setSchId(notice.getSchId());
return request; return request;
} }
+15 -17
View File
@@ -54,17 +54,13 @@ sms:
notice: notice:
flow: flow:
enabled: true enabled: true
# flow-center 地址不再单独配置:见上方 yh.open.services.flowcenter(SDK 门面 YschFlowOpenApi)。 # flow-center 地址不再单独配置:见上方 yh.open.services.flow-center(SDK 门面 YschFlowOpenApi)。
# legacy 回落键 notice.flow.base-url 已删,且代码侧不再给默认值——两者都缺时启动即失败 # legacy 回落键 notice.flow.base-url 不配即空,且代码侧不给默认值——与目录配置都缺时构造期
# (构造期 fail-fast),不静默打 localhost。 # fail-fast,不静默打 localhost。
# 本应用对外可达基址(flow-center 回调探活/事件推送打这里;本地=后端直连端口) # 回调地址相关键(callback-base / callback-base-path / callback-path)已于 2026-09-24 删除:
callback-base: http://localhost:8101 # 注册地址改由 yh.flow-callback 三段推导(见下方该段),这三个键已无任何代码引用。
# 回调端点:网关契约 /{target}/oapi/** 的首段已由 target 承担,端点不再带应用名前缀(2026-09-24 # 前端基址 front-base-url 已于 2026-09-26 删除:apply bizUrl 改传裸相对路径
# 由 /oapi/notice/flow/callback 收敛为 starter 默认值);必须与 yh.flow-callback.path 一致, # (/list/detail?serid=...),绝对化由接收方按页面归属(notice)拼——Notice 零地址类配置。
# 否则 flow-center 探活/推送 404。不变式由 NoticeFlowCallbackConfigContractTest 冻结。
callback-path: /oapi/flow/callback
# Notice 前端基址(apply bizUrl 拼接,审批人点击跳回通知详情)
front-base-url: http://localhost:8077
# 短信到点补发调度(§3.2 修 BP-R5-2):fixedDelay 5min,扫"已发布+要短信+未发过+已到点" # 短信到点补发调度(§3.2 修 BP-R5-2):fixedDelay 5min,扫"已发布+要短信+未发过+已到点"
# 补发(幂等由 notice.sms_sent_at 原子认领保证)。生产加列见 deploy/sql/20260913_add_sms_sent_at.sql # 补发(幂等由 notice.sms_sent_at 原子认领保证)。生产加列见 deploy/sql/20260913_add_sms_sent_at.sql
sms: sms:
@@ -113,17 +109,19 @@ yh:
# 期望不一致时自动重新注册,替代"删镜像行→重新保存绑定"的手工恢复路径。注意重注册会轮换 secret。 # 期望不一致时自动重新注册,替代"删镜像行→重新保存绑定"的手工恢复路径。注意重注册会轮换 secret。
reconcile-on-register: true reconcile-on-register: true
# 注册地址不再显式配置(2026-09-24 摘除 callback-url):由 starter 推导 # 注册地址不再显式配置(2026-09-24 摘除 callback-url):由 starter 推导
# (open-host ?: yh.open.base-host) + /{target ?: base-path} + path # (open-host ?: yh.open.base-host) + /{前缀} + path
# 前缀三层回落:target → base-path → 运行时自证 context-path(ServletContext 容器级,
# 2026-09-26 起);本地无挂载前缀时空串/"/"视为无前缀,URL 退化为 host+path,不报错
# ⇒ 生产 = http://open-gateway-service:10013 + /notice + /oapi/flow/callback # ⇒ 生产 = http://open-gateway-service:10013 + /notice + /oapi/flow/callback
# base-path 本地无 context-path 不填(生产由 projman 注入 /notice)。改地址回滚:把全地址写回 # base-path 可不填(运行时会自证出与真实 ctx 同源的值;显式配置仅作覆盖/逃生口保留)。
# callback-url 即可(回滚绳),无需回滚镜像。 # 改地址回滚:把全地址写回 callback-url 即可(回滚绳),无需回滚镜像。
open: open:
# 四域服务目录零配置:userbase/userclass/usermsg/flowcenter 枚举兜底 = base-host + 默认 ctx # 四域服务目录零配置:userbase/userclass/usermsg/flow-center 枚举兜底 = base-host + 默认 ctx
base-host: http://base.yunhaixingtong.com base-host: http://base.yunhaixingtong.com
# flowcenter 显式指向本地流程中心(默认枚举兜底会打 base 域网关,网关无 /oapi 路由); # flow-center 显式指向本地流程中心(默认枚举兜底会打 base 域网关,网关无 /oapi 路由);
# 生产改容器直连 http://flow-center-service:10008/flow-center(projman 注入)。 # 生产改容器直连 http://flow-center-service:10008/flow-center(projman 注入)。
services: services:
flowcenter: http://localhost:8085 flow-center: http://localhost:8085
# usermsg 显式指向本地消息中心(生产部署 UserMsg 后改容器直连,如 http://usermsg-service:8080/usermsg) # usermsg 显式指向本地消息中心(生产部署 UserMsg 后改容器直连,如 http://usermsg-service:8080/usermsg)
usermsg: http://localhost:8080 usermsg: http://localhost:8080
# env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见 # env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见
@@ -9,15 +9,18 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/** /**
* 回调路径配置契约测试(防"过期值"回归;2026-09-20 迁 starter 后改版)。<br><br> * 回调路径配置契约测试(防"过期值"回归;2026-09-20 迁 starter 后改版)。<br><br>
* 不变式:{@code yh.flow-callback.path}(starter 接收端点的真实映射来源)必须与 * 不变式:{@code yh.flow-callback.path}(starter 接收端点的真实映射来源)必须等于冻结字面量
* {@code notice.flow.callback-path}(回调地址尾段的历史真源)逐字一致且等于冻结字面量 * {@code /oapi/flow/callback}——不一致时 flow-center 探活/推送全部 404,而本地单测因为走
* {@code /oapi/flow/callback}——不一致时 flow-center 探活/推送全部 404, * 伪服务端不会发现。<br>
* 而本地单测因为走伪服务端不会发现。<br>
* 2026-09-24:字面量由 {@code /oapi/notice/flow/callback} 收敛为 starter 默认值 * 2026-09-24:字面量由 {@code /oapi/notice/flow/callback} 收敛为 starter 默认值
* (网关契约 {@code /{target}/oapi/**} 首段已由 target 承担,端点内不再带应用名前缀)。<br> * (网关契约 {@code /{target}/oapi/**} 首段已由 target 承担,端点内不再带应用名前缀);
* 同日清理 {@code notice.flow.callback-base} / {@code callback-base-path} / {@code callback-path}
* 三个已无代码引用的历史键——注册地址改由 {@code yh.flow-callback} 三段推导,
* 故本测试不再做双键一致性比对,改为断言历史键不得回流。<br>
* 迁移背景:端点由 yh-flow-callback-spring-boot-starter 的 FlowCallbackReceiverController * 迁移背景:端点由 yh-flow-callback-spring-boot-starter 的 FlowCallbackReceiverController
* 提供(@RequestMapping("${yh.flow-callback.path:...}")),不再有本地控制器类可反射。 * 提供(@RequestMapping("${yh.flow-callback.path:...}")),不再有本地控制器类可反射。
*/ */
@@ -47,31 +50,25 @@ class NoticeFlowCallbackConfigContractTest {
} }
@Test @Test
@DisplayName("yh.flow-callback.path 与 notice.flow.callback-path 一致且等于冻结字面量") @DisplayName("yh.flow-callback.path 等于冻结字面量(starter 端点映射唯一真源)")
void starterPathMatchesLegacyCallbackPath() throws Exception { void starterPathMatchesFrozenLiteral() throws Exception {
Object starterConfigured = yamlValue("yh.flow-callback.path"); Object starterConfigured = yamlValue("yh.flow-callback.path");
String starterPath = starterConfigured == null String starterPath = starterConfigured == null
? "/oapi/flow/callback" // starter 代码默认值(未配置时端点会映射到这里) ? "/oapi/flow/callback" // starter 代码默认值(未配置时端点会映射到这里)
: String.valueOf(starterConfigured); : String.valueOf(starterConfigured);
Object legacyConfigured = yamlValue("notice.flow.callback-path"); assertEquals(FROZEN_CALLBACK_PATH, starterPath,
String legacyPath = legacyConfigured == null "yh.flow-callback.path 变了:flow-center 派发会 404,请同步生产配置与注册契约");
? new NoticeFlowProperties().getCallbackPath() // 未配置 → 代码默认值
: String.valueOf(legacyConfigured);
assertEquals(FROZEN_CALLBACK_PATH, legacyPath, "回调路径历史真源变了,请同步生产配置与注册契约");
assertEquals(legacyPath, starterPath,
"yh.flow-callback.path 与 notice.flow.callback-path 不一致:flow-center 派发会 404");
} }
@Test @Test
@DisplayName("application.yml 的 notice.flow.callback-base-path 与部署 context-path 一致(缺省=空)") @DisplayName("历史回调键(callback-base / -base-path / -path)已清理,不得回流")
void callbackBasePathMatchesContextPath() throws Exception { void legacyCallbackKeysAreGone() throws Exception {
Object configured = yamlValue("notice.flow.callback-base-path"); assertNull(yamlValue("notice.flow.callback-base"),
String actual = configured == null ? "" : String.valueOf(configured); "notice.flow.callback-base 已无代码引用,不应再出现在配置里");
// 本地进程无 context-path(server.servlet.context-path 未设)→ 申报空串; assertNull(yamlValue("notice.flow.callback-base-path"),
// 生产由 projman 注入 /notice(见 deploy/notice.yaml)。配错的表现是回调 401。 "notice.flow.callback-base-path 已无代码引用,不应再出现在配置里");
Object contextPath = yamlValue("server.servlet.context-path"); assertNull(yamlValue("notice.flow.callback-path"),
assertEquals(contextPath == null ? "" : String.valueOf(contextPath), actual); "notice.flow.callback-path 已无代码引用,不应再出现在配置里");
} }
} }
@@ -117,7 +117,6 @@ class NoticeFlowPublishTest {
NoticeFlowProperties props = new NoticeFlowProperties(); NoticeFlowProperties props = new NoticeFlowProperties();
props.setEnabled(true); props.setEnabled(true);
props.setFrontBaseUrl("http://localhost:8077");
AppAuthProperties appAuth = new AppAuthProperties(); AppAuthProperties appAuth = new AppAuthProperties();
appAuth.setAppKey("100617"); appAuth.setAppKey("100617");
@@ -333,7 +332,7 @@ class NoticeFlowPublishTest {
assertEquals("notice", b.getBizType()); assertEquals("notice", b.getBizType());
assertEquals("77", b.getBizId()); assertEquals("77", b.getBizId());
assertEquals("九月开学安排", b.getBizTitle()); assertEquals("九月开学安排", b.getBizTitle());
assertEquals("http://localhost:8077/#/list/detail?serid=77", b.getBizUrl()); assertEquals("/list/detail?serid=77", b.getBizUrl());
assertEquals(201, b.getSubmitterId()); assertEquals(201, b.getSubmitterId());
assertEquals("张三", b.getSubmitterName()); assertEquals("张三", b.getSubmitterName());
// 快照:title + summary(纯文本) + attachmentNames[] // 快照:title + summary(纯文本) + attachmentNames[]
@@ -159,6 +159,9 @@ class NoticeUserMsgNotifierTest {
ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class); ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class);
verify(client).sendUserMsg(captor.capture()); verify(client).sendUserMsg(captor.capture());
assertEquals("notice.expedite", captor.getValue().getBizType()); assertEquals("notice.expedite", captor.getValue().getBizType());
// Phase 2:细分分类(notice.expedite)不配基址键,靠归属声明 jumpApp=notice 命中应用键
assertEquals("notice", captor.getValue().getJumpApp());
assertEquals("/list/detail?serid=6&type=1", captor.getValue().getJumpPath());
assertTrue(captor.getValue().getMsgNo().startsWith("ntc-exp-6-")); assertTrue(captor.getValue().getMsgNo().startsWith("ntc-exp-6-"));
} }