fix(flow): unify workflow notices and draft reliability

(cherry picked from commit 1632fbb83d24be1523e6fa809ed2ad0ca3e6cb0b)
This commit is contained in:
PIGCLOUD committed 2026-11-03 14:55:33 +08:00
1 parent 4f6e796c55
commit d0aa3a7284
17 files changed
+289 -108

No files matched your search

+8 -4
View File
@@ -1533,7 +1533,11 @@ CREATE TABLE `sys_message` (
`update_by` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '删除时间',
`tenant_id` bigint DEFAULT NULL COMMENT '租户',
PRIMARY KEY (`id`)
`business_type` varchar(32) DEFAULT NULL COMMENT '业务类型',
`business_id` varchar(64) DEFAULT NULL COMMENT '业务记录ID',
`request_key` varchar(100) DEFAULT NULL COMMENT '调用方事件标识',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_message_request` (`tenant_id`,`request_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='站内信息';
-- ----------------------------
@@ -3243,7 +3247,7 @@ CREATE TABLE IF NOT EXISTS flow_draft (
kind varchar(16) NOT NULL, flow_id varchar(64) NOT NULL DEFAULT '', title varchar(200) NOT NULL,
payload longtext NOT NULL, revision bigint NOT NULL, updated_at timestamp(3) NOT NULL,
PRIMARY KEY(id), KEY idx_draft_owner(tenant_id,user_id,kind,updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS flow_notice (
id varchar(36) NOT NULL, tenant_id bigint NOT NULL, user_id varchar(64) NOT NULL,
event_key varchar(180) NOT NULL, kind varchar(16) NOT NULL, title varchar(200) NOT NULL,
@@ -3253,7 +3257,7 @@ CREATE TABLE IF NOT EXISTS flow_notice (
reminder_key varchar(64) NULL,
PRIMARY KEY(id), UNIQUE KEY uk_notice_event(tenant_id,user_id,event_key), KEY idx_notice_owner(tenant_id,user_id,created_at),
UNIQUE KEY uk_notice_reminder(tenant_id,reminder_key), KEY idx_notice_due(status,scheduled_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS flow_callback (
id varchar(36) NOT NULL, tenant_id bigint NOT NULL, task_id varchar(64) NOT NULL,
instance_id varchar(64) NOT NULL, target_url varchar(2000) NOT NULL, payload longtext NOT NULL,
@@ -3262,4 +3266,4 @@ CREATE TABLE IF NOT EXISTS flow_callback (
lease_token varchar(36) NULL, lease_until timestamp(3) NULL, last_error varchar(500) NULL,
created_at timestamp(3) NOT NULL, updated_at timestamp(3) NOT NULL,
PRIMARY KEY(id), UNIQUE KEY uk_callback_task(tenant_id,task_id), KEY idx_callback_due(status,next_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+5 -1
View File
@@ -1484,7 +1484,11 @@ CREATE TABLE `sys_message` (
`update_by` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '删除时间',
`tenant_id` bigint DEFAULT NULL COMMENT '租户',
PRIMARY KEY (`id`)
`business_type` varchar(32) DEFAULT NULL COMMENT '业务类型',
`business_id` varchar(64) DEFAULT NULL COMMENT '业务记录ID',
`request_key` varchar(100) DEFAULT NULL COMMENT '调用方事件标识',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_message_request` (`tenant_id`,`request_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='站内信息';
-- ----------------------------
+3 -3
View File
@@ -296,7 +296,7 @@ CREATE TABLE IF NOT EXISTS flow_draft (
kind varchar(16) NOT NULL, flow_id varchar(64) NOT NULL DEFAULT '', title varchar(200) NOT NULL,
payload longtext NOT NULL, revision bigint NOT NULL, updated_at timestamp(3) NOT NULL,
PRIMARY KEY(id), KEY idx_draft_owner(tenant_id,user_id,kind,updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS flow_notice (
id varchar(36) NOT NULL, tenant_id bigint NOT NULL, user_id varchar(64) NOT NULL,
event_key varchar(180) NOT NULL, kind varchar(16) NOT NULL, title varchar(200) NOT NULL,
@@ -306,7 +306,7 @@ CREATE TABLE IF NOT EXISTS flow_notice (
reminder_key varchar(64) NULL,
PRIMARY KEY(id), UNIQUE KEY uk_notice_event(tenant_id,user_id,event_key), KEY idx_notice_owner(tenant_id,user_id,created_at),
UNIQUE KEY uk_notice_reminder(tenant_id,reminder_key), KEY idx_notice_due(status,scheduled_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS flow_callback (
id varchar(36) NOT NULL, tenant_id bigint NOT NULL, task_id varchar(64) NOT NULL,
instance_id varchar(64) NOT NULL, target_url varchar(2000) NOT NULL, payload longtext NOT NULL,
@@ -315,4 +315,4 @@ CREATE TABLE IF NOT EXISTS flow_callback (
lease_token varchar(36) NULL, lease_until timestamp(3) NULL, last_error varchar(500) NULL,
created_at timestamp(3) NOT NULL, updated_at timestamp(3) NOT NULL,
PRIMARY KEY(id), UNIQUE KEY uk_callback_task(tenant_id,task_id), KEY idx_callback_due(status,next_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
@@ -4,8 +4,7 @@ import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 流程通知的持久化状态;编码与 flow_notice.status 及升级 SQL 保持一致。 待触发提醒发送后转为 SENT,任务结束时转为 CANCELLED;列表仅展示
* SENT。
* 流程通知事件状态;READY 经统一信息推送发送后转为 SENT,待触发提醒随任务结束取消。
*/
@Getter
@RequiredArgsConstructor
@@ -14,7 +13,10 @@ public enum FlowNoticeStatusEnum {
/** 等待截止时间,到期后确定当前审批人。 */
PENDING("PENDING"),
/** 通知已生成,允许接收人查看及标记已读。 */
/** 等待投递到统一站内信。 */
READY("READY"),
/** 已投递到统一站内信,已读状态由 sys_message_relation 维护。 */
SENT("SENT"),
/** 任务已经结束,取消尚未发送的提醒。 */
@@ -97,4 +97,22 @@ public class FlowDurableConfiguration {
}
}
/** 复用流程事件表投递站内信;失败延迟一分钟再试,不阻塞引擎事务。 */
@Scheduled(fixedDelayString = "${flow.delivery.scan-delay:10000}", scheduler = "flowDeliveryScheduler")
public void scanNotices() {
var notices = jdbc.queryForList(
"SELECT id FROM flow_notice WHERE status=? AND scheduled_at<=CURRENT_TIMESTAMP(3) ORDER BY scheduled_at LIMIT 100",
FlowNoticeStatusEnum.READY.getCode());
for (var row : notices) {
try {
reliability.getObject().deliverNotice(String.valueOf(row.get("id")));
}
catch (Exception exception) {
jdbc.update("UPDATE flow_notice SET scheduled_at=TIMESTAMPADD(SECOND,60,CURRENT_TIMESTAMP(3)) WHERE id=? AND status=?",
row.get("id"), FlowNoticeStatusEnum.READY.getCode());
log.warn("流程站内信暂未发送,事件 {}", row.get("id"), exception);
}
}
}
}
@@ -55,8 +55,9 @@ public class FlowWorkspaceController {
*/
@Operation(summary = "分页查询个人草稿")
@GetMapping("/drafts")
public R<List<Map<String, Object>>> list(@RequestParam String kind, @RequestParam(defaultValue = "1") int page) {
return R.ok(drafts.list(tenant(), user(), kind, page));
public R<List<Map<String, Object>>> list(@RequestParam String kind, @RequestParam(defaultValue = "1") int page,
@RequestParam(required = false) String flowId) {
return R.ok(drafts.list(tenant(), user(), kind, page, flowId));
}
/**
@@ -84,30 +85,6 @@ public class FlowWorkspaceController {
return R.ok(drafts.delete(tenant(), user(), id, revision));
}
/**
* 查询个人站内通知。
* @param page 页码
* @return 通知列表
*/
@Operation(summary = "分页查询个人通知")
@GetMapping("/notices")
public R<List<Map<String, Object>>> notices(@RequestParam(defaultValue = "1") int page) {
return R.ok(reliability.notices(tenant(), user(), page));
}
/**
* 标记个人通知已读。
* @param id 通知 ID
* @return 操作结果
*/
@Operation(summary = "标记个人通知已读")
@SysLog("标记个人通知已读")
@PutMapping("/notices/{id}/read")
public R<Void> read(@PathVariable String id) {
reliability.read(tenant(), user(), id);
return R.ok();
}
/**
* 对自己发起的实例催办。
* @param id 实例 ID
@@ -77,12 +77,13 @@ public class FlowDraftService {
* @param user 当前用户
* @param kind DESIGN 或 APPLICATION
* @param page 从 1 开始的页码
* @param flowId 可选的流程 ID,为空时查询全部流程
* @return 每页最多 50 个草稿
*/
public List<Map<String, Object>> list(long tenant, long user, String kind, int page) {
public List<Map<String, Object>> list(long tenant, long user, String kind, int page, String flowId) {
return jdbc.queryForList(
"SELECT id,kind,flow_id,title,revision,updated_at FROM flow_draft WHERE tenant_id=? AND user_id=? AND kind=? ORDER BY updated_at DESC LIMIT 50 OFFSET ?",
tenant, user, kind, Math.max(0L, (long) page - 1) * 50);
"SELECT id,kind,flow_id,title,revision,updated_at FROM flow_draft WHERE tenant_id=? AND user_id=? AND kind=? AND (? IS NULL OR flow_id=?) ORDER BY updated_at DESC, id DESC LIMIT 50 OFFSET ?",
tenant, user, kind, flowId, flowId, Math.max(0L, (long) page - 1) * 50);
}
/**
@@ -3,6 +3,8 @@ package com.pig4cloud.pigx.flow.service;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.pig4cloud.pigx.admin.api.dto.MessageNoticeDTO;
import com.pig4cloud.pigx.admin.api.feign.RemoteMessageService;
import com.pig4cloud.pigx.common.data.tenant.TenantBroker;
import com.pig4cloud.pigx.flow.dto.Node;
import com.pig4cloud.pigx.flow.constant.NodeStatusEnum;
@@ -36,6 +38,8 @@ public class FlowReliabilityService {
private final TaskService taskService;
private final RemoteMessageService messageService;
/**
* 在引擎事务内记录任务事件;持久化失败必须回滚引擎操作。
* @param task 引擎当前任务
@@ -108,10 +112,10 @@ public class FlowReliabilityService {
private void notice(long tenant, String user, String event, String kind, String title, String task,
String instance) {
jdbc.update(
"INSERT INTO flow_notice(id,tenant_id,user_id,event_key,kind,title,task_id,instance_id,created_at,sent_at,status) VALUES(?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),?) ON DUPLICATE KEY UPDATE event_key=event_key",
"INSERT INTO flow_notice(id,tenant_id,user_id,event_key,kind,title,task_id,instance_id,created_at,scheduled_at,status) VALUES(?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),?) ON DUPLICATE KEY UPDATE event_key=event_key",
UUID.randomUUID().toString(), tenant, user, event, kind,
StrUtil.maxLength(StrUtil.blankToDefault(title, "流程任务"), 200), task, instance,
FlowNoticeStatusEnum.SENT.getCode());
FlowNoticeStatusEnum.READY.getCode());
}
/**
@@ -124,30 +128,33 @@ public class FlowReliabilityService {
*/
@Transactional
public int urge(long tenant, long user, String instance) {
requireStarter(tenant, user, instance, true);
String flowName = requireStarter(tenant, user, instance, true);
var tasks = taskService.createTaskQuery()
.processInstanceId(instance)
.taskTenantId(String.valueOf(tenant))
.active()
.list();
if (tasks.isEmpty())
throw new IllegalStateException("流程当前没有可催办任务");
throw new IllegalStateException("当前没有待处理的审批,暂时无法催办");
var recent = jdbc.queryForList(
"SELECT id FROM flow_notice WHERE tenant_id=? AND instance_id=? AND kind='URGE' AND created_at>?",
tenant, instance, Timestamp.from(Instant.now().minusSeconds(600)));
if (!recent.isEmpty())
throw new IllegalStateException("已发送催办,请十分钟后再试");
throw new IllegalStateException("已提交过催办,两次催办需间隔十分钟");
int count = 0;
String event = UUID.randomUUID().toString();
for (var task : tasks) {
if (StrUtil.isBlank(task.getAssignee()))
continue;
notice(tenant, task.getAssignee(), "URGE:" + event + StrUtil.COLON + task.getId(), "URGE", task.getName(),
// 建模时会在节点名称末尾追加五位随机数,通知展示时移除内部后缀。
String nodeName = StrUtil.blankToDefault(task.getName(), "审批").replaceFirst("\\[\\d{5}\\]$", StrUtil.EMPTY);
notice(tenant, task.getAssignee(), "URGE:" + event + StrUtil.COLON + task.getId(), "URGE",
flowName + " · " + nodeName,
task.getId(), instance);
count++;
}
if (count == 0)
throw new IllegalStateException("流程当前没有已分配的待办任务");
throw new IllegalStateException("当前待办尚未分配处理人,暂时无法催办");
return count;
}
@@ -175,35 +182,43 @@ public class FlowReliabilityService {
return;
}
jdbc.update(
"UPDATE flow_notice SET user_id=?,title=?,status=?,sent_at=CURRENT_TIMESTAMP(3),created_at=CURRENT_TIMESTAMP(3) WHERE id=? AND tenant_id=? AND status=?",
"UPDATE flow_notice SET user_id=?,title=?,status=?,scheduled_at=CURRENT_TIMESTAMP(3),created_at=CURRENT_TIMESTAMP(3) WHERE id=? AND tenant_id=? AND status=?",
task.getAssignee(), StrUtil.maxLength(StrUtil.blankToDefault(task.getName(), "流程任务"), 200),
FlowNoticeStatusEnum.SENT.getCode(), watches.get(0).get("id"), tenant,
FlowNoticeStatusEnum.READY.getCode(), watches.get(0).get("id"), tenant,
FlowNoticeStatusEnum.PENDING.getCode());
}
/**
* 分页读取个人通知。
* @param tenant 当前租户
* @param user 当前用户
* @param page 从 1 开始的页码
* @return 最近通知,每页最多 50 条
* 投递已提交的流程事件到统一站内信;行锁防止扫描器并发发送同一事件。
* @param id 流程通知事件 ID
*/
public List<Map<String, Object>> notices(long tenant, long user, int page) {
return jdbc.queryForList(
"SELECT * FROM flow_notice WHERE tenant_id=? AND user_id=? AND status=? ORDER BY created_at DESC LIMIT 50 OFFSET ?",
tenant, String.valueOf(user), FlowNoticeStatusEnum.SENT.getCode(), Math.max(0L, (long) page - 1) * 50);
}
/**
* 标记个人通知已读,其他用户记录不受影响。
* @param tenant 当前租户
* @param user 当前用户
* @param id 通知 ID
*/
public void read(long tenant, long user, String id) {
jdbc.update(
"UPDATE flow_notice SET read_at=COALESCE(read_at,CURRENT_TIMESTAMP(3)) WHERE id=? AND tenant_id=? AND user_id=? AND status=?",
id, tenant, String.valueOf(user), FlowNoticeStatusEnum.SENT.getCode());
@Transactional
public void deliverNotice(String id) {
var rows = jdbc.queryForList("SELECT * FROM flow_notice WHERE id=? AND status=? FOR UPDATE", id,
FlowNoticeStatusEnum.READY.getCode());
if (rows.isEmpty())
return;
var row = rows.get(0);
long tenant = ((Number) row.get("tenant_id")).longValue();
MessageNoticeDTO notice = new MessageNoticeDTO();
notice.setCategory("1");
notice.setAllFlag("0");
notice.setUserIds(List.of(Long.parseLong(String.valueOf(row.get("user_id")))));
String label = switch (String.valueOf(row.get("kind"))) {
case "URGE" -> "催办";
case "OVERDUE" -> "超时提醒";
default -> "待办";
};
notice.setTitle(label + StrUtil.COLON + row.get("title"));
notice.setContent("您有一条" + label + "消息,请打开对应审批任务处理。");
notice.setBusinessType("FLOW_TASK");
notice.setBusinessId(String.valueOf(row.get("task_id")));
notice.setRequestKey("flow:" + id);
var result = TenantBroker.applyAs(tenant, t -> messageService.sendNotice(notice));
if (result == null || result.getCode() != 0)
throw new IllegalStateException("流程站内信发送失败");
jdbc.update("UPDATE flow_notice SET status=?,sent_at=CURRENT_TIMESTAMP(3) WHERE id=? AND tenant_id=?",
FlowNoticeStatusEnum.SENT.getCode(), id, tenant);
}
/**
@@ -246,15 +261,16 @@ public class FlowReliabilityService {
FlowNoticeStatusEnum.CANCELLED.getCode(), tenant, task, FlowNoticeStatusEnum.PENDING.getCode());
}
private void requireStarter(long tenant, long user, String instance, boolean running) {
private String requireStarter(long tenant, long user, String instance, boolean running) {
var rows = jdbc.queryForList(
"SELECT status FROM process_instance_record WHERE process_instance_id=? AND user_id=? AND tenant_id=? AND del_flag='0' LIMIT 1"
"SELECT status,name FROM process_instance_record WHERE process_instance_id=? AND user_id=? AND tenant_id=? AND del_flag='0' LIMIT 1"
+ (running ? " FOR UPDATE" : StrUtil.EMPTY),
instance, user, tenant);
if (rows.isEmpty())
throw new IllegalStateException("流程不存在或无权访问");
if (running && !Objects.equals(Convert.toInt(rows.get(0).get("status")), NodeStatusEnum.JXZ.getCode()))
throw new IllegalStateException("流程已结束,不能催办");
return StrUtil.blankToDefault(Convert.toStr(rows.get(0).get("name")), "流程申请");
}
}
@@ -171,6 +171,9 @@ public class TaskServiceImpl implements ITaskService {
.taskTenantId(TenantContextHolder.getTenantId().toString())
.taskAssignee(String.valueOf(userId))
.singleResult());
if (!view && task.isEmpty()) {
return R.failed("任务已结束或已转交,无法办理");
}
TaskResultDto taskResultDto = new TaskResultDto();
if (task.isPresent()) {
@@ -347,7 +350,7 @@ public class TaskServiceImpl implements ITaskService {
ProcessNodeRecordAssignUser currentAssignment = findActiveAssignment(task.getId(), userId);
ProcessNodeRecord nodeRecord = lockActiveNodeRecord(task, currentAssignment, TenantContextHolder.getTenantId());
if (nodeRecord == null) {
return R.failed("当前审批节点已结束,无法操作");
return R.failed("当前审批环节已结束,无法继续操作");
}
Map<String, Object> taskLocalParamMap = taskParamDto.getTaskLocalParamMap();
@@ -449,7 +452,7 @@ public class TaskServiceImpl implements ITaskService {
@Transactional(rollbackFor = Exception.class)
public R addSign(AddSignTaskDto addSignTaskDto) {
if (addSignTaskDto == null || StrUtil.isBlank(addSignTaskDto.getTaskId())) {
return R.failed("任务ID不能为空");
return R.failed("审批信息不完整,请重新打开");
}
List<Long> requestedUserIds = addSignTaskDto.getTargetUserIdList();
if (CollUtil.isEmpty(requestedUserIds)) {
@@ -481,7 +484,7 @@ public class TaskServiceImpl implements ITaskService {
ProcessNodeRecordAssignUser sourceAssignment = findActiveAssignment(sourceTask.getId(), userId);
ProcessNodeRecord nodeRecord = lockActiveNodeRecord(sourceTask, sourceAssignment, tenantId);
if (nodeRecord == null) {
return R.failed("当前审批节点已结束,无法加签");
return R.failed("当前审批环节已结束,无法加签");
}
String flowId = NodeUtil.getFlowId(sourceTask.getProcessDefinitionId());
@@ -489,17 +492,17 @@ public class TaskServiceImpl implements ITaskService {
.getPublishedNode(sourceTask.getProcessDefinitionId(), sourceTask.getTaskDefinitionKey())
.getData();
if (!isParallelCountersign(node)) {
return R.failed("仅并行会签节点支持加签");
return R.failed("只有多人同时审批的会签环节支持加签");
}
if (!Boolean.TRUE.equals(node.getAllowAddSign())) {
return R.failed("当前节点未开启加签");
return R.failed("当前审批环节未开启加签功能");
}
if (isProcessStarter(sourceTask.getProcessInstanceId(), userId)) {
return R.failed("流程发起人不能发起加签");
}
if (!isOriginalApprover(sourceTask, sourceAssignment, userId)) {
return R.failed("只有未完成审批的原始审批人可以加签");
return R.failed("只有最初指定的审批人,且尚未完成审批时,才能加签");
}
List<Long> participantUserIds = listParticipantUserIds(nodeRecord.getId());
@@ -577,7 +580,7 @@ public class TaskServiceImpl implements ITaskService {
}
catch (FlowableException exception) {
log.warn("动态加签时多实例节点已不可用: taskId={}", sourceTask.getId(), exception);
return rollbackFailed("当前审批节点已结束,无法加签");
return rollbackFailed("当前审批环节已结束,无法加签");
}
LinkedHashSet<Long> participantUserIdsAfter = new LinkedHashSet<>(participantUserIds);
@@ -600,11 +603,11 @@ public class TaskServiceImpl implements ITaskService {
@Override
public R delegateTask(TaskParamDto taskParamDto) {
if (taskParamDto == null || StrUtil.isBlank(taskParamDto.getTaskId())) {
return R.failed("任务ID不能为空");
return R.failed("审批信息不完整,请重新打开");
}
if (taskParamDto.getTargetUserIdList() == null || taskParamDto.getTargetUserIdList().size() != 1
|| taskParamDto.getTargetUserIdList().get(0) == null) {
return R.failed("委托仅支持选择一名用户");
return R.failed("一次只能委托给一人");
}
long userId = SecurityUtils.getUser().getId();
Long tenantId = TenantContextHolder.getTenantId();
@@ -620,10 +623,10 @@ public class TaskServiceImpl implements ITaskService {
ProcessNodeRecordAssignUser sourceAssignment = findActiveAssignment(task.getId(), userId);
ProcessNodeRecord nodeRecord = lockActiveNodeRecord(task, sourceAssignment, tenantId);
if (nodeRecord == null) {
return R.failed("当前审批节点已结束,无法委托");
return R.failed("当前审批环节已结束,无法委托");
}
if (listParticipantUserIds(nodeRecord.getId()).contains(targetUserId)) {
return R.failed("委托目标已参与当前会签");
return R.failed("所选人员已参与本轮会签,请选择其他人");
}
if (queryValidTenantUsers(taskParamDto.getTargetUserIdList(), tenantId).size() != 1) {
return R.failed("委托用户不存在、不属于当前租户或已被锁定");
@@ -678,11 +681,11 @@ public class TaskServiceImpl implements ITaskService {
@Transactional(rollbackFor = Exception.class)
public R setAssignee(TaskParamDto taskParamDto) {
if (taskParamDto == null || StrUtil.isBlank(taskParamDto.getTaskId())) {
return R.failed("任务ID不能为空");
return R.failed("审批信息不完整,请重新打开");
}
if (taskParamDto.getTargetUserIdList() == null || taskParamDto.getTargetUserIdList().size() != 1
|| taskParamDto.getTargetUserIdList().get(0) == null) {
return R.failed("转办仅支持选择一名用户");
return R.failed("一次只能转办给一人");
}
long userId = SecurityUtils.getUser().getId();
@@ -701,17 +704,17 @@ public class TaskServiceImpl implements ITaskService {
ProcessNodeRecordAssignUser sourceAssignment = findActiveAssignment(task.getId(), userId);
ProcessNodeRecord nodeRecord = lockActiveNodeRecord(task, sourceAssignment, tenantId);
if (nodeRecord == null) {
return R.failed("当前审批节点已结束,无法转办");
return R.failed("当前审批环节已结束,无法转办");
}
if (listParticipantUserIds(nodeRecord.getId()).contains(targetUserId)) {
return R.failed("转办目标已参与当前会签");
return R.failed("所选人员已参与本轮会签,请选择其他人");
}
if (queryValidTenantUsers(List.of(targetUserId), tenantId).size() != 1) {
return R.failed("转办用户不存在或不属于当前租户");
}
if (sourceAssignment == null) {
return R.failed("未找到当前任务的执行人记录");
return R.failed("未找到当前审批的处理人记录,请联系管理员");
}
Map<String, Object> taskLocalParamMap = taskParamDto.getTaskLocalParamMap();
@@ -778,7 +781,7 @@ public class TaskServiceImpl implements ITaskService {
public R withdrawProcessInstance(TaskParamDto taskParamDto) {
String processInstanceId = taskParamDto.getProcessInstanceId();
if (StrUtil.isBlank(processInstanceId)) {
return R.failed("流程实例ID不能为空");
return R.failed("申请信息不完整,请重新打开");
}
long userId = SecurityUtils.getUser().getId();
@@ -798,7 +801,7 @@ public class TaskServiceImpl implements ITaskService {
Object rejectToStarter = runtimeService.getVariable(processInstanceId,
ProcessInstanceConstant.REJECT_TO_STARTER_VAR);
if (Boolean.TRUE.equals(rejectToStarter)) {
return R.failed("驳回待提交状态不可撤回");
return R.failed("申请已被驳回,正在等待重新提交,不能撤回");
}
}
catch (Exception e) {
@@ -813,7 +816,7 @@ public class TaskServiceImpl implements ITaskService {
.ne(ProcessNodeRecordAssignUser::getStatus, NodeStatusEnum.JXZ.getCode()))
.exists();
if (hasProcessedApproval) {
return R.failed("首个审批节点已处理,无法撤回");
return R.failed("第一步审批已处理,无法撤回");
}
return terminateProcessInstance(processInstanceId, ProcessInstanceConstant.FINISH_REASON_WITHDRAW);
@@ -861,7 +864,7 @@ public class TaskServiceImpl implements ITaskService {
// 2. 验证是 root 任务(发起人节点)
if (!StrUtil.equals(task.getTaskDefinitionKey(), "root")) {
return R.failed("非重新提交任务,无法操作");
return R.failed("当前审批不支持重新提交");
}
String processInstanceId = task.getProcessInstanceId();
@@ -1046,7 +1049,7 @@ public class TaskServiceImpl implements ITaskService {
private R terminateProcessInstance(String reqProcessInstanceId, String finishReason) {
if (StrUtil.isBlank(reqProcessInstanceId)) {
return R.failed("流程实例ID不能为空");
return R.failed("申请信息不完整,请重新打开");
}
List<String> allStopProcessInstanceIdList = getAllStopProcessInstanceIdList(reqProcessInstanceId);
@@ -1,6 +1,9 @@
package com.pig4cloud.pigx.flow.service;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.admin.api.dto.MessageNoticeDTO;
import com.pig4cloud.pigx.admin.api.feign.RemoteMessageService;
import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder;
import cn.hutool.json.JSONUtil;
import com.pig4cloud.pigx.flow.dto.Node;
import com.pig4cloud.pigx.flow.constant.FlowNoticeStatusEnum;
@@ -43,6 +46,10 @@ class FlowWorkspaceTests {
private FlowDraftService drafts;
private RemoteMessageService messages;
private final List<MessageNoticeDTO> sentNotices = new ArrayList<>();
private HttpServer server;
private final List<String> eventIds = new ArrayList<>();
@@ -61,7 +68,7 @@ class FlowWorkspaceTests {
if (!sql.isBlank())
jdbc.execute(sql);
jdbc.execute(
"CREATE TABLE process_instance_record(id bigint primary key,process_instance_id varchar(64),tenant_id bigint,user_id bigint,status int,del_flag char(1))");
"CREATE TABLE process_instance_record(id bigint primary key,process_instance_id varchar(64),tenant_id bigint,user_id bigint,status int,del_flag char(1),name varchar(200))");
var manager = new DataSourceTransactionManager(source);
transaction = new TransactionTemplate(manager);
var configuration = new SpringProcessEngineConfiguration();
@@ -92,7 +99,13 @@ class FlowWorkspaceTests {
node.setEventConfig("http://127.0.0.1:" + server.getAddress().getPort() + "/callback");
var nodes = mock(IProcessNodeDataService.class);
when(nodes.getPublishedNode(anyString(), anyString())).thenReturn(R.ok(node));
var target = new FlowReliabilityService(jdbc, nodes, engine.getTaskService());
messages = mock(RemoteMessageService.class);
when(messages.sendNotice(any())).thenAnswer(invocation -> {
assertThat(TenantContextHolder.getTenantId()).isEqualTo(1L);
sentNotices.add(invocation.getArgument(0));
return R.ok(123L);
});
var target = new FlowReliabilityService(jdbc, nodes, engine.getTaskService(), messages);
var proxy = new ProxyFactory(target);
proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource()));
reliability = (FlowReliabilityService) proxy.getProxy();
@@ -106,7 +119,7 @@ class FlowWorkspaceTests {
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="test">
<process id="approval" isExecutable="true">
<startEvent id="start"/><sequenceFlow id="s1" sourceRef="start" targetRef="approve"/>
<userTask id="approve" name="审批" flowable:assignee="1001"/><sequenceFlow id="s2" sourceRef="approve" targetRef="end"/><endEvent id="end"/>
<userTask id="approve" name="审批[51421]" flowable:assignee="1001"/><sequenceFlow id="s2" sourceRef="approve" targetRef="end"/><endEvent id="end"/>
</process>
</definitions>
""")
@@ -137,25 +150,34 @@ class FlowWorkspaceTests {
assertThat(logMapper.writeValueAsString(new Object[] { input })).doesNotContain("private-title", "secret",
"payload", "title");
assertThat(FlowNoticeStatusEnum.values()).extracting(FlowNoticeStatusEnum::getCode)
.containsExactly("PENDING", "SENT", "CANCELLED", "DISABLED");
.containsExactly("PENDING", "READY", "SENT", "CANCELLED", "DISABLED");
var design = drafts.save(1, 7,
new FlowDraftService.Draft(null, "DESIGN", "approval", "设计", "{\"step1\":{}}", 0));
var application = drafts.save(1, 7,
new FlowDraftService.Draft(null, "APPLICATION", "approval", "申请", "{\"formData\":{\"amount\":8}}", 0));
assertThat(new FlowDraftService(jdbc).get(1, 7, design.get("id").toString()).get("payload"))
.isEqualTo("{\"step1\":{}}");
assertThat(drafts.list(1, 7, "APPLICATION", 1)).hasSize(1);
assertThat(drafts.list(1, 7, "APPLICATION", 1, null)).hasSize(1);
assertThat(drafts.list(1, 7, "APPLICATION", 1, "approval")).hasSize(1);
assertThat(drafts.list(1, 7, "APPLICATION", 1, "other-flow")).isEmpty();
assertThat(drafts.list(1, 8, "APPLICATION", 1, "approval")).isEmpty();
assertThat(drafts.get(1, 7, application.get("id").toString()).get("payload"))
.isEqualTo("{\"formData\":{\"amount\":8}}");
var instance = engine.getRuntimeService()
.startProcessInstanceByKeyAndTenantId("approval", java.util.Map.of(), "1");
jdbc.update("INSERT INTO process_instance_record VALUES(1,?,1,7,1,'0')", instance.getId());
jdbc.update("INSERT INTO process_instance_record VALUES(1,?,1,7,1,'0','差旅申请')", instance.getId());
var task = engine.getTaskService().createTaskQuery().singleResult();
assertThat(reliability.notices(1, 1001, 1)).hasSize(1);
String notice = reliability.notices(1, 1001, 1).get(0).get("id").toString();
reliability.read(1, 1001, notice);
assertThat(reliability.notices(1, 1001, 1).get(0).get("read_at")).isNotNull();
assertThat(reliability.notices(1, 0, 1)).isEmpty();
verifyNoInteractions(messages);
String notice = jdbc.queryForObject("SELECT id FROM flow_notice WHERE status='READY'", String.class);
reliability.deliverNotice(notice);
reliability.deliverNotice(notice);
assertThat(sentNotices).hasSize(1);
assertThat(sentNotices.get(0).getUserIds()).containsExactly(1001L);
assertThat(sentNotices.get(0).getBusinessType()).isEqualTo("FLOW_TASK");
assertThat(sentNotices.get(0).getBusinessId()).isEqualTo(task.getId());
assertThat(sentNotices.get(0).getRequestKey()).isEqualTo("flow:" + notice);
assertThat(sentNotices.get(0).getCategory()).isEqualTo("1");
assertThat(sentNotices.get(0).getAllFlag()).isEqualTo("0");
assertThat(jdbc.queryForObject("SELECT count(*) FROM flow_notice WHERE status='PENDING'", Integer.class))
.isEqualTo(1);
engine.getRuntimeService().suspendProcessInstanceById(instance.getId());
@@ -177,12 +199,19 @@ class FlowWorkspaceTests {
var scheduler = new FlowDurableConfiguration(jdbc, provider, mock(ObjectProvider.class));
scheduler.scanReminders();
scheduler.scanReminders();
assertThat(reliability.notices(1, 2002, 1)).hasSize(2);
assertThat(jdbc.queryForObject("SELECT count(*) FROM flow_notice WHERE tenant_id=1 AND user_id='2002'", Integer.class)).isEqualTo(2);
engine.getTaskService().setAssignee(task.getId(), "1001");
assertThat(reliability.notices(1, 1001, 1)).hasSize(2);
assertThat(jdbc.queryForObject("SELECT count(*) FROM flow_notice WHERE tenant_id=1 AND user_id='1001'", Integer.class)).isEqualTo(2);
engine.getTaskService().setAssignee(task.getId(), "2002");
assertThat(reliability.urge(1, 7, instance.getId())).isEqualTo(1);
assertThatThrownBy(() -> reliability.urge(1, 7, instance.getId())).hasMessageContaining("十分钟");
scheduler.scanNotices();
assertThat(sentNotices).extracting(MessageNoticeDTO::getTitle)
.anyMatch(title -> title.startsWith("待办:"))
.anyMatch(title -> title.equals("催办:差旅申请 · 审批"))
.anyMatch(title -> title.startsWith("超时提醒:"));
assertThat(sentNotices.stream().filter(message -> message.getTitle().startsWith("超时提醒:"))
.map(MessageNoticeDTO::getUserIds).toList()).containsExactly(List.of(2002L));
engine.getTaskService().complete(task.getId());
assertThat(jdbc.queryForObject("SELECT count(*) FROM flow_notice WHERE status='PENDING'", Integer.class))
.isZero();
@@ -237,11 +266,26 @@ class FlowWorkspaceTests {
})).hasMessage("rollback");
assertThat(jdbc.queryForObject("SELECT count(*) FROM flow_notice", Integer.class)).isZero();
assertThat(engine.getTaskService().createTaskQuery().count()).isZero();
verifyNoInteractions(messages);
var instance = engine.getRuntimeService()
.startProcessInstanceByKeyAndTenantId("approval", java.util.Map.of(), "1");
jdbc.update("INSERT INTO process_instance_record VALUES(1,?,1,7,1,'0')", instance.getId());
jdbc.update("INSERT INTO process_instance_record VALUES(1,?,1,7,1,'0','差旅申请')", instance.getId());
assertThatThrownBy(() -> reliability.urge(2, 7, instance.getId())).hasMessageContaining("无权");
assertThatThrownBy(() -> reliability.callbacks(1, 8, instance.getId())).hasMessageContaining("无权");
String notice = jdbc.queryForObject("SELECT id FROM flow_notice WHERE status='READY'", String.class);
doReturn(R.failed("发送暂不可用"), R.ok(123L)).when(messages).sendNotice(any());
ObjectProvider<FlowReliabilityService> provider = mock(ObjectProvider.class);
when(provider.getObject()).thenReturn(reliability);
var scheduler = new FlowDurableConfiguration(jdbc, provider, mock(ObjectProvider.class));
scheduler.scanNotices();
assertThat(jdbc.queryForObject("SELECT status FROM flow_notice WHERE id=?", String.class, notice)).isEqualTo("READY");
assertThat(jdbc.queryForObject("SELECT scheduled_at>CURRENT_TIMESTAMP(3) FROM flow_notice WHERE id=?", Boolean.class, notice)).isTrue();
jdbc.update("UPDATE flow_notice SET scheduled_at=CURRENT_TIMESTAMP(3) WHERE id=?", notice);
scheduler.scanNotices();
assertThat(jdbc.queryForObject("SELECT status FROM flow_notice WHERE id=?", String.class, notice)).isEqualTo("SENT");
var captor = org.mockito.ArgumentCaptor.forClass(MessageNoticeDTO.class);
verify(messages, times(2)).sendNotice(captor.capture());
assertThat(captor.getAllValues()).extracting(MessageNoticeDTO::getRequestKey).containsExactly("flow:" + notice, "flow:" + notice);
String task = engine.getTaskService().createTaskQuery().singleResult().getId();
jdbc.execute("DROP TABLE flow_callback");
assertThatThrownBy(() -> engine.getTaskService().complete(task)).isInstanceOf(RuntimeException.class);
@@ -189,6 +189,15 @@ class TaskServiceImplTests {
TenantContextHolder.clear();
}
@Test
void queryTaskRejectsUnavailableTaskInHandlingMode() {
TaskQuery query = mock(TaskQuery.class, org.mockito.Answers.RETURNS_SELF);
when(taskService.createTaskQuery()).thenReturn(query);
R result = taskServiceImpl.queryTask("finished-or-transferred", false);
assertThat(result.getCode()).isEqualTo(1);
assertThat(result.getMsg()).isEqualTo("任务已结束或已转交,无法办理");
}
@Test
void stopProcessInstancePropagatesTerminateReasonBeforeMovingToEnd() {
ChangeActivityStateBuilder stateChangeBuilder = prepareTerminationFlow(null);
@@ -50,4 +50,13 @@ public class MessageNoticeDTO {
*/
private Integer sort = 0;
/** 业务类型;FLOW_TASK 表示流程审批任务,空值按普通消息打开。 */
private String businessType;
/** 业务记录 ID;FLOW_TASK 时为任务 ID。 */
private String businessId;
/** 调用方事件标识,同一租户内用于重试去重;普通消息可不传。 */
private String requestKey;
}
@@ -107,4 +107,13 @@ public class SysMessageEntity extends Model<SysMessageEntity> {
@Schema(description = "租户")
private Long tenantId;
/** 业务类型;FLOW_TASK 表示流程审批任务,空值按普通消息打开。 */
private String businessType;
/** 业务记录 ID;FLOW_TASK 时为任务 ID。 */
private String businessId;
/** 调用方事件标识,同一租户内用于重试去重;普通消息可不传。 */
private String requestKey;
}
@@ -25,6 +25,7 @@ import com.pig4cloud.pigx.admin.api.dto.MessageNoticeDTO;
import com.pig4cloud.pigx.admin.api.dto.MessageSmsDTO;
import com.pig4cloud.pigx.common.core.constant.ServiceNameConstants;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.common.feign.annotation.NoToken;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -71,7 +72,8 @@ public interface RemoteMessageService {
* @param noticeDTO 站内消息 DTO
* @return {@link R }
*/
@PostMapping("/sysMessage/send/notice")
@NoToken
@PostMapping("/sysMessage/internal/notice")
R sendNotice(@RequestBody MessageNoticeDTO noticeDTO);
}
@@ -267,4 +267,16 @@ public class SysMessageController {
return sysMessageService.sendNotice(noticeDTO);
}
/**
* 业务服务内部发送站内信,复用统一消息存储和接收人关系。
* @param noticeDTO 消息及业务跳转信息
* @return 消息 ID
*/
@Inner
@PostMapping("/internal/notice")
@Operation(summary = "内部发送站内信")
public R sendInternalNotice(@Valid @RequestBody MessageNoticeDTO noticeDTO) {
return sysMessageService.sendNotice(noticeDTO);
}
}
@@ -608,6 +608,16 @@ public class SysMessageServiceImpl extends ServiceImpl<SysMessageMapper, SysMess
@Override
@Transactional(rollbackFor = Exception.class)
public R sendNotice(MessageNoticeDTO noticeDTO) {
if (StrUtil.isNotBlank(noticeDTO.getRequestKey())) {
SysMessageEntity existing = baseMapper.selectOne(Wrappers.<SysMessageEntity>lambdaQuery()
.eq(SysMessageEntity::getRequestKey, noticeDTO.getRequestKey()));
if (existing != null) {
return R.ok(existing.getId());
}
}
if (!YesNoEnum.YES.getCode().equals(noticeDTO.getAllFlag()) && CollUtil.isEmpty(noticeDTO.getUserIds())) {
return R.failed("指定用户站内信必须提供接收人");
}
// 构建消息VO
SysMessageVO messageVO = new SysMessageVO();
messageVO.setCategory(noticeDTO.getCategory());
@@ -615,6 +625,9 @@ public class SysMessageServiceImpl extends ServiceImpl<SysMessageMapper, SysMess
messageVO.setContent(noticeDTO.getContent());
messageVO.setAllFlag(noticeDTO.getAllFlag());
messageVO.setSort(noticeDTO.getSort());
messageVO.setBusinessType(noticeDTO.getBusinessType());
messageVO.setBusinessId(noticeDTO.getBusinessId());
messageVO.setRequestKey(StrUtil.blankToDefault(noticeDTO.getRequestKey(), null));
// 设置接收用户列表
if (CollUtil.isNotEmpty(noticeDTO.getUserIds())) {
@@ -0,0 +1,58 @@
package com.pig4cloud.pigx.admin.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.pig4cloud.pigx.admin.api.dto.MessageNoticeDTO;
import com.pig4cloud.pigx.admin.api.entity.SysMessageEntity;
import com.pig4cloud.pigx.admin.api.entity.SysMessageRelationEntity;
import com.pig4cloud.pigx.admin.mapper.SysMessageMapper;
import com.pig4cloud.pigx.admin.mapper.SysMessageRelationMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class SysMessageNoticeTests {
@Test
void flowNoticeUsesSharedMessageAndReceiverAndDeduplicatesRetries() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "notice"), SysMessageEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relation"), SysMessageRelationEntity.class);
var mapper = mock(SysMessageMapper.class);
var relations = mock(SysMessageRelationMapper.class);
var service = new SysMessageServiceImpl(null, null, relations, null, null, null, null);
ReflectionTestUtils.setField(service, "baseMapper", mapper);
var saved = new AtomicReference<SysMessageEntity>();
when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenAnswer(invocation -> saved.get());
when(mapper.insert(any(SysMessageEntity.class))).thenAnswer(invocation -> {
SysMessageEntity entity = invocation.getArgument(0);
entity.setId(123L);
saved.set(entity);
return 1;
});
var notice = new MessageNoticeDTO();
notice.setCategory("1");
notice.setTitle("待办:财务审批");
notice.setContent("请打开审批任务处理");
notice.setUserIds(List.of(1001L));
notice.setBusinessType("FLOW_TASK");
notice.setBusinessId("task-A");
notice.setRequestKey("flow:event-A");
assertThat(service.sendNotice(notice).getData()).isEqualTo(123L);
assertThat(service.sendNotice(notice).getData()).isEqualTo(123L);
verify(mapper, times(1)).insert(any(SysMessageEntity.class));
assertThat(saved.get().getBusinessType()).isEqualTo("FLOW_TASK");
assertThat(saved.get().getBusinessId()).isEqualTo("task-A");
assertThat(saved.get().getRequestKey()).isEqualTo("flow:event-A");
var recipient = ArgumentCaptor.forClass(SysMessageRelationEntity.class);
verify(relations, times(1)).insert(recipient.capture());
assertThat(recipient.getValue().getMsgId()).isEqualTo(123L);
assertThat(recipient.getValue().getUserId()).isEqualTo(1001L);
}
}