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,三断言全过
220 lines
11 KiB
Java
220 lines
11 KiB
Java
package com.yunhai.flowcenter.notify;
|
||
|
||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||
import com.yunhai.flowcenter.entity.FlwTask;
|
||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||
import lombok.RequiredArgsConstructor;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.stereotype.Component;
|
||
import org.springframework.transaction.event.TransactionPhase;
|
||
import org.springframework.transaction.event.TransactionalEventListener;
|
||
|
||
import java.util.Collections;
|
||
|
||
/**
|
||
* 站内信/待办角标通知(概览 §七 契约,回调通知"业务系统"、本类通知"人",两通道互不替代):
|
||
* - 任务到达:msgType=2、bizType=flow.task、msgNo=task:{taskId}、jumpPath=bizUrl 优先(空回落
|
||
* /task/{taskId})、todo +1
|
||
* - 任务被办理:todo -1(办理人)
|
||
* - 完成/拒绝/撤回:msgType=1、bizType=flow.result、content 必填(结果+意见)、msgNo 带 version
|
||
* (重提重启多轮终态互不幂等吞掉)
|
||
* - 催办:bizType=flow.urge、msgNo=urge:{taskId}:{第N次}
|
||
* 全部在 AFTER_COMMIT 监听器里发送,失败不阻断(UserMsgClient 内部容错)。
|
||
*/
|
||
@Slf4j
|
||
@Component
|
||
@RequiredArgsConstructor
|
||
public class UserMsgNotifier {
|
||
|
||
private final UserMsgClient userMsgClient;
|
||
private final FlwTaskRepository taskRepository;
|
||
private final FlwInstanceRepository instanceRepository;
|
||
|
||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||
public void onFlowEvent(FlowEvent event) {
|
||
switch (event.getType()) {
|
||
case TASK_CREATED:
|
||
notifyTaskCreated(event);
|
||
break;
|
||
case TASK_HANDLED:
|
||
notifyTaskHandled(event);
|
||
break;
|
||
case RETURNED:
|
||
notifyReturned(event);
|
||
break;
|
||
case TRANSFERRED:
|
||
notifyTaskCreated(event);
|
||
break;
|
||
case CC_SENT:
|
||
notifyCc(event);
|
||
break;
|
||
case COMPLETED:
|
||
case REJECTED:
|
||
case WITHDRAWN:
|
||
notifyResult(event);
|
||
break;
|
||
case URGE:
|
||
notifyUrge(event);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void notifyTaskCreated(FlowEvent event) {
|
||
FlwTask task = taskRepository.findById(event.getTaskId()).orElse(null);
|
||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||
if (task == null || instance == null) {
|
||
return;
|
||
}
|
||
String title = "您有新的待办:" + safe(instance.getBizTitle());
|
||
String content = "「" + safe(instance.getBizTitle()) + "」等待您审批"
|
||
+ (task.getNodeName() == null ? "" : "(" + task.getNodeName() + ")");
|
||
// 跳转路径:办件自带 bizUrl(业务方详情页,改造后为裸相对路径)优先——应用内审批免跳
|
||
// 流程中心,页面归属=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.getApplyAppCode() : null;
|
||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||
Collections.singletonList(task.getAssigneeId()), 2,
|
||
"flow.task", String.valueOf(instance.getId()), title, content,
|
||
jumpPath, jumpApp, "task:" + task.getId(), 30);
|
||
userMsgClient.todoInc(instance.getSchId().intValue(),
|
||
Collections.singletonList(task.getAssigneeId()), 1);
|
||
}
|
||
|
||
private void notifyTaskHandled(FlowEvent event) {
|
||
FlwTask task = taskRepository.findById(event.getTaskId()).orElse(null);
|
||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||
if (task == null || instance == null) {
|
||
return;
|
||
}
|
||
userMsgClient.todoInc(instance.getSchId().intValue(),
|
||
Collections.singletonList(task.getAssigneeId()), -1);
|
||
}
|
||
|
||
/**
|
||
* 退回:通知被退回节点的全部新待办人(RETURNED 事件 data.targetNodeIds 由服务层带上——
|
||
* 退回重建任务在事件发布前已提交,这里按节点查待办即可);todo 角标由 notifyTaskCreated
|
||
* 通用链路在引擎重建任务时已发过,这里只补一条带退回意见的消息。
|
||
*/
|
||
private void notifyReturned(FlowEvent event) {
|
||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||
if (instance == null || event.getData() == null) {
|
||
return;
|
||
}
|
||
Object fromNode = event.getData().get("fromNodeName");
|
||
Object opinion = event.getData().get("opinion");
|
||
StringBuilder content = new StringBuilder("「").append(safe(instance.getBizTitle())).append("」")
|
||
.append("被退回(").append(fromNode == null ? "" : fromNode).append(")");
|
||
if (opinion != null && !String.valueOf(opinion).isEmpty()) {
|
||
content.append(",意见:").append(opinion);
|
||
}
|
||
java.util.List<Integer> targets = event.getData().get("targetUserIds") == null
|
||
? Collections.emptyList()
|
||
: (java.util.List<Integer>) event.getData().get("targetUserIds");
|
||
if (targets.isEmpty()) {
|
||
return;
|
||
}
|
||
userMsgClient.sendUserMsg(instance.getSchId().intValue(), targets, 1,
|
||
"flow.returned", String.valueOf(instance.getId()),
|
||
"办件被退回:" + safe(instance.getBizTitle()), content.toString(),
|
||
"/instance/" + instance.getId(), null,
|
||
"returned:" + instance.getId() + ":" + instance.getVersion(), 30);
|
||
}
|
||
|
||
/**
|
||
* 抄送(2026-09-23 NOTIFY 节点):msgType=1 通知类(待办才是 2),bizType=flow.cc、
|
||
* msgNo=cc:{ccId}(逐人逐节点一行一消息,重走重复抄送不互吞);跳转与任务到达同策略
|
||
* (bizUrl 优先,空回落办件详情)。★ 不调 todoInc——知会不占待办角标、不进待办列表。
|
||
*/
|
||
private void notifyCc(FlowEvent event) {
|
||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||
Object ccUserId = event.getData() == null ? null : event.getData().get("ccUserId");
|
||
if (instance == null || ccUserId == null) {
|
||
return;
|
||
}
|
||
Object nodeName = event.getData().get("nodeName");
|
||
Object ccId = event.getData().get("ccId");
|
||
String bizTitle = safe(instance.getBizTitle());
|
||
String content = "「" + bizTitle + "」抄送给您"
|
||
+ (nodeName == null || String.valueOf(nodeName).isEmpty()
|
||
? "" : "(" + nodeName + ")");
|
||
// 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=apply 调用方 applyAppCode)优先,
|
||
// 空回落办件详情(属自己)
|
||
boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
|
||
String jumpPath = useBizUrl ? instance.getBizUrl() : "/instance/" + instance.getId();
|
||
String jumpApp = useBizUrl ? instance.getApplyAppCode() : null;
|
||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||
Collections.singletonList((Integer) ccUserId), 1,
|
||
"flow.cc", String.valueOf(instance.getId()),
|
||
"您有新的抄送:" + bizTitle, content,
|
||
jumpPath, jumpApp, "cc:" + ccId, 30);
|
||
}
|
||
|
||
private void notifyResult(FlowEvent event) {
|
||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||
if (instance == null) {
|
||
return;
|
||
}
|
||
Object result = event.getData() == null ? null : event.getData().get("result");
|
||
String outcome;
|
||
String bizTitle = safe(instance.getBizTitle());
|
||
switch (event.getType()) {
|
||
case COMPLETED:
|
||
outcome = "已通过审批";
|
||
break;
|
||
case REJECTED:
|
||
outcome = "审批被拒绝";
|
||
break;
|
||
default:
|
||
outcome = "已撤回";
|
||
break;
|
||
}
|
||
StringBuilder content = new StringBuilder("您提交的「" + bizTitle + "」" + outcome);
|
||
if (result != null) {
|
||
content.append("(结果:").append(result).append(")");
|
||
}
|
||
Object opinion = event.getData() == null ? null : event.getData().get("opinion");
|
||
if (opinion != null && !String.valueOf(opinion).isEmpty()) {
|
||
content.append(",意见:").append(opinion);
|
||
}
|
||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||
Collections.singletonList(instance.getSubmitterId()), 1,
|
||
"flow.result", String.valueOf(instance.getId()),
|
||
"办件状态更新:" + bizTitle, content.toString(),
|
||
"/instance/" + instance.getId(), null,
|
||
"result:" + instance.getId() + ":" + instance.getVersion(), 30);
|
||
}
|
||
|
||
private void notifyUrge(FlowEvent event) {
|
||
FlwTask task = taskRepository.findById(event.getTaskId()).orElse(null);
|
||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||
if (task == null || instance == null) {
|
||
return;
|
||
}
|
||
int count = task.getUrgeCount() == null ? 1 : task.getUrgeCount();
|
||
String content = "「" + safe(instance.getBizTitle()) + "」任务("
|
||
+ (task.getNodeName() == null ? task.getNodeId() : task.getNodeName())
|
||
+ ")被催办(第 " + count + " 次),请尽快办理";
|
||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||
Collections.singletonList(task.getAssigneeId()), 1,
|
||
"flow.urge", String.valueOf(instance.getId()),
|
||
"催办提醒:" + safe(instance.getBizTitle()), content,
|
||
"/task/" + task.getId(), null, "urge:" + task.getId() + ":" + count, 7);
|
||
}
|
||
|
||
private FlwInstance instanceOf(Long instanceId) {
|
||
return instanceId == null ? null : instanceRepository.findById(instanceId).orElse(null);
|
||
}
|
||
|
||
private static String safe(String s) {
|
||
return s == null ? "" : s;
|
||
}
|
||
}
|