fix(msg): 消息 jump_app 换源 apply 调用方 app_code 快照,替代 bizType 冒充
build / build-push (push) Successful in 49s

P1/P2 联合修复(消息归属 + jump_app 值域污染):
- FlwInstance 新增 apply_app_key/apply_app_code 双列快照(自动 DDL)
- apply 从验签上下文(yh.app.auth)取调用方身份,by-key 一次换得 app_code
  (域间边界只在 apply 转换一次;查无/不可达降级 null,不阻断 apply)
- UserMsgNotifier 两处 jumpApp 改源 applyAppCode——bizType 是消息分类
  不是路由键;降级空时接收方按发送方回落(现状行为)
- FlowDefAdminService.save appKey 必填化(2026-10-12 拍板):新建必须
  显式传;升版本/直改可继承源行,源行为空必须补填(存量 19 条 NULL 随编辑收敛)
- 单测:SaveTest 新增必填拦截 3 用例、BizUrlTest 换源断言+降级用例(19 绿)
- E2E(8085+UserBase):100617->apply_app_code=notice 正向落库、
  100018(app_code NULL)->降级成功、错签名 401,三断言全过
This commit is contained in:
chenxing
2026-10-12 10:44:07 +08:00
parent d687ba7d56
commit b4856a480d
10 changed files with 160 additions and 30 deletions
@@ -54,8 +54,9 @@ public class OapiFlowController {
@PostMapping("/apply")
public OapiModels.ApplyResultVo apply(@RequestParam("schId") Long schId,
@RequestAttribute(value = "yh.app.auth", required = false) String appKey,
@RequestBody OapiModels.ApplyBody body) {
return flowService.apply(schId, body);
return flowService.apply(schId, appKey, body);
}
@GetMapping("/instance")
@@ -74,6 +74,22 @@ public class FlwInstance extends BaseEntity {
@Column(name = "biz_url", length = 500)
private String bizUrl;
/**
* 代发起调用方 app_key 快照(验签上下文 yh.app.auth,2026-10-12):验签域身份,
* 审计/对账用——与 flw_callback_reg/flw_def 的 app_key 同一口径。
*/
@Column(name = "apply_app_key", length = 32)
private String applyAppKey;
/**
* 代发起调用方 app_code 快照(apply 时按 app_key 查 UserBase /oapi/app/by-key 换得):
* 消息 jump_app 的权威来源(路由/跳转/判权域标识,projman app.name 口径如 notice/svc-hall)。
* biz_url 页面即调用方页面,验签身份=页面属主由平台结构保证。UserBase 不可达时为空
* →消息 jumpApp 空、回落发送方归属(现状行为),不阻断 apply。
*/
@Column(name = "apply_app_code", length = 32)
private String applyAppCode;
/** 业务摘要 JSON 快照:办理页展示用(只收摘要,不收富文本正文,概览 §十边界) */
@Column(name = "biz_snapshot", columnDefinition = "LONGTEXT")
private String bizSnapshot;
@@ -67,9 +67,10 @@ public class UserMsgClient {
/**
* 发送站内信(content 必填;msgNo 幂等,重发安全)。
*
* @param jumpApp 跳转页面归属应用(可空):页面属业务方(bizUrl 落地页)时填业务方应用名
* (=instance.bizType),接收方优先按它查基址;页面属流程中心自己(/task、/instance)
* 传 null,由接收方按 bizType 回落
* @param jumpApp 跳转页面归属应用(可空,值域=app_code 如 notice/svc-hall):页面属业务方
* (bizUrl 落地页)时填 apply 调用方 app_code(=instance.applyAppCode,apply
* 时由验签身份按 by-key 换得),接收方优先按它查基址;页面属流程中心自己
* (/task、/instance)传 null,由接收方按发送方 appKey 回落
*/
public void sendUserMsg(Integer schId, List<Integer> userIds, Integer msgType, String bizType,
String bizId, String title, String content, String jumpPath, String jumpApp,
@@ -73,11 +73,13 @@ public class UserMsgNotifier {
String content = "「" + safe(instance.getBizTitle()) + "」等待您审批"
+ (task.getNodeName() == null ? "" : "(" + task.getNodeName() + ")");
// 跳转路径:办件自带 bizUrl(业务方详情页,改造后为裸相对路径)优先——应用内审批免跳
// 流程中心,页面归属=业务方(jumpApp=instance.bizType,接收方按它查基址拼绝对地址);
// 未传 bizUrl 回落流程中心办理页 /task/{taskId},页面属自己(jumpApp 留空按 bizType 回落)
// 流程中心,页面归属=apply 调用方(jumpApp=instance.applyAppCode,apply 时由验签身份
// 按_by-key 换得的 app_code,接收方按它查基址拼绝对地址);applyAppCode 空(老数据/
// UserBase 不可达)或未传 bizUrl 回落流程中心页(/task/{taskId}、/instance/{id}),
// 页面属自己(jumpApp 留空按发送方回落)。bizType 是消息分类,不是路由键,不再冒充。
boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
String jumpPath = useBizUrl ? instance.getBizUrl() : "/task/" + task.getId();
String jumpApp = useBizUrl ? instance.getBizType() : null;
String jumpApp = useBizUrl ? instance.getApplyAppCode() : null;
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
Collections.singletonList(task.getAssigneeId()), 2,
"flow.task", String.valueOf(instance.getId()), title, content,
@@ -143,10 +145,11 @@ public class UserMsgNotifier {
String content = "「" + bizTitle + "」抄送给您"
+ (nodeName == null || String.valueOf(nodeName).isEmpty()
? "" : "(" + nodeName + ")");
// 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=业务方)优先,空回落办件详情(属自己)
// 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=apply 调用方 applyAppCode)优先,
// 空回落办件详情(属自己)
boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
String jumpPath = useBizUrl ? instance.getBizUrl() : "/instance/" + instance.getId();
String jumpApp = useBizUrl ? instance.getBizType() : null;
String jumpApp = useBizUrl ? instance.getApplyAppCode() : null;
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
Collections.singletonList((Integer) ccUserId), 1,
"flow.cc", String.valueOf(instance.getId()),
@@ -75,13 +75,14 @@ public class FlowDefAdminService {
* - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1)
* - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本"
* - id 空:永远新开一条流程线(flowGuid 生成,version=1);同编码多条线合法,前端保存前有软提示
* appKey=归属应用(筛选维度):新建/草稿直改直接落库;升版本(baseGuid/已发布行)缺省继承源线,传非空则覆盖
* appKey=归属应用(筛选维度,2026-10-12 拍板必填):新建必须显式传;升版本/直改可继承源行,
* 但源行为空时必须显式补填——存量 NULL 线随编辑自然收敛(白名单只校验显式传入值,继承值不重校)
*/
@Transactional
public FlwDef save(Long schId, Long id, String code, String name, String contentJson, String baseGuid, String appKey) {
String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim();
// 归属应用白名单(2026-10-10 应用设置):显式传入非空时必须 ∈ flw_app(sch, enabled=1);
// 不传(升版本继承源线)不拦。不在学校池/未开启的应用由此拦下(拍板 D2:不做存量兜底)
// 继承值不重校(既成事实,应用停用不应卡死升版本)。不在校池/未开启的应用由此拦下(拍板 D2:不做存量兜底)
if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试");
@@ -134,6 +135,12 @@ public class FlowDefAdminService {
}
def.setCode(code.trim());
def.setName(name.trim());
// 归属应用必填(2026-10-12 拍板):各分支归一后落库值不得为空——新建必须显式传;
// 升版本/直改继承源行,源行为空的存量线编辑时强制补填(19 条存量随编辑自然收敛)
if (def.getAppKey() == null || def.getAppKey().trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"归属应用必填:请选择归属应用后再保存(应用需先在 流程管理 > 应用设置 中开启)");
}
def.setContentJson(contentJson);
// 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主)
if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) {
@@ -56,6 +56,10 @@ public class FlowInstanceService {
public final Integer submitterId;
public final String submitterName;
public final String formDataJson;
/** 调用方验签身份快照(yh.app.auth;本地/无签名场景为 null) */
public final String applyAppKey;
/** 调用方 app_code 快照(by-key 一次换得;UserBase 不可达为 null→消息回落发送方归属) */
public final String applyAppCode;
}
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
@@ -84,6 +88,8 @@ public class FlowInstanceService {
instance.setBizUrl(cmd.bizUrl);
instance.setBizSnapshot(cmd.bizSnapshot);
instance.setFormDataJson(cmd.formDataJson);
instance.setApplyAppKey(cmd.applyAppKey);
instance.setApplyAppCode(cmd.applyAppCode);
instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName);
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
@@ -106,6 +112,9 @@ public class FlowInstanceService {
instance.setBizUrl(cmd.bizUrl);
instance.setBizSnapshot(cmd.bizSnapshot);
instance.setFormDataJson(cmd.formDataJson);
// 调用方身份快照跟随最新一次 apply 刷新(重启=新一轮代发起,调用方可能已变)
instance.setApplyAppKey(cmd.applyAppKey);
instance.setApplyAppCode(cmd.applyAppCode);
instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName);
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
@@ -79,6 +79,7 @@ public class OapiFlowService {
private final FlowEngine flowEngine;
private final ObjectMapper objectMapper;
private final OpenApiUserClient userClient;
private final com.yunhai.platform.openapi.base.OpenApiAppClient appClient;
private final DiagramTokenService tokenService;
/** 本应用身份(回调探活补应用签名头;未装配时裸发,接收方 /oapi/* 会 401) */
@@ -101,9 +102,10 @@ public class OapiFlowService {
.map(this::toSummary).orElse(null);
}
/** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService) */
/** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份,
* 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */
@Transactional
public OapiModels.ApplyResultVo apply(Long schId, OapiModels.ApplyBody body) {
public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) {
if (schId == null || body.getFlowGuid() == null || body.getBizType() == null
|| body.getBizId() == null || body.getSubmitterId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
@@ -114,12 +116,14 @@ public class OapiFlowService {
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + body.getFlowGuid()));
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()));
asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey));
FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd);
OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo();
vo.setInstanceId(outcome.getInstanceId());
@@ -127,6 +131,30 @@ public class OapiFlowService {
return vo;
}
/**
* 调用方 app_key → app_code 一次转换(域间边界:验签域→路由域,只在 apply 做这一次)。
* 查无/不可达降级 null——消息 jumpApp 空、归属回落发送方(现状行为),不阻断 apply;
* jump_app 值域契约(=app_code)由本方法单点保证,替代历史上用 bizType 冒充的写法。
*/
private String resolveApplyAppCode(String appKey) {
if (appKey == null) {
return null;
}
try {
com.fasterxml.jackson.databind.JsonNode app = appClient.getAppByKey(appKey);
String code = app == null ? null : app.path("appCode").asText(null);
if (code == null || code.trim().isEmpty()) {
log.warn("[flow-center] apply 调用方 app_code 解析为空(by-key 查无):appKey={},消息归属将回落发送方", appKey);
return null;
}
return code.trim();
} catch (Exception e) {
log.warn("[flow-center] apply 调用方 app_code 解析失败(UserBase 不可达?):appKey={}:{}",
appKey, e.toString());
return null;
}
}
/** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */
public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) {
FlwInstance instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId)