对接学校OA待办

This commit is contained in:
2026-09-16 13:51:50 +08:00
parent 72b2413c00
commit 19d6cebeb0
14 changed files with 849 additions and 0 deletions
@@ -0,0 +1,20 @@
package com.budwk.app.base.portal;
import com.budwk.app.base.portal.model.PortalTodoResponse;
/** 成功发送记录的持久化服务,不负责再次请求门户。 */
public interface PortalTodoLogService {
/**
* 保存一次已确认成功的发送快照;使用当前业务事务,外层回滚时记录也可能回滚。
* @param requestId 本次调用的32位关联编号,对应日志主键
* @param requestUrl 实际发送接口地址,不传认证头
* @param requestBody 实际发送的完整JSON字符串
* @param responseBody 门户原始响应正文
* @param response 解析后的成功结果,含HTTP状态、业务码、提示、data及traceId
* @param requestTime 请求开始的毫秒时间戳
* @param elapsedMillis 接口耗时,不含数据库保存时间
* @return 无返回值;保存异常由调用端单独处理,不改变门户调用结果
*/
void recordSuccess(String requestId, String requestUrl, String requestBody, String responseBody,
PortalTodoResponse response, long requestTime, long elapsedMillis);
}
@@ -0,0 +1,26 @@
package com.budwk.app.base.portal;
import com.budwk.app.base.portal.model.PortalTodoRequest;
import com.budwk.app.base.portal.model.PortalTodoSendRequest;
import com.budwk.app.base.portal.model.PortalTodoResponse;
/** 信息门户待办接口,可在业务 service 中注入后独立调用。 */
public interface PortalTodoService {
/**
* @param request 业务名称、唯一 ID、单据编码、接收人登录账号及标题、正文、完整 PC 链接
* @return 调用结果;success 表示门户成功,skipped 表示开关拦截,msg/traceId 用于排查
*/
PortalTodoResponse sendTodo(PortalTodoSendRequest request);
/**
* @param request 与发送时完全一致的业务名称、唯一 ID、单据编码和接收人账号
* @return 调用结果;审核完成使用此方法,门户保留已办记录
*/
PortalTodoResponse doneTodo(PortalTodoRequest request);
/**
* @param request 与发送时完全一致的业务名称、唯一 ID、单据编码和接收人账号
* @return 调用结果;撤销或删除业务时使用,失败不会抛出网络异常阻断业务
*/
PortalTodoResponse removeTodo(PortalTodoRequest request);
}
@@ -0,0 +1,58 @@
# 门户待办调用
在业务 service 中注入 `PortalTodoService`。三个方法均返回 `PortalTodoResponse`,不会因门户 HTTP 失败主动抛出异常。
```java
@Inject
private PortalTodoService portalTodoService;
// 同一待办发送、完成、删除时保留下面四项定位数据。
PortalTodoSendRequest request = new PortalTodoSendRequest();
request.setEntityName("职工福利审核");
request.setEntityId(taskId);
request.setEntityNumber(businessNumber);
request.setTargets(List.of(receiverLoginName));
request.setSubject("职工福利审核待办");
request.setContent("您有一条职工福利申请需要审核。");
request.setLink(auditPageUrl);
request.setMobileLink(h5AuditPageUrl);
request.setCreator(senderLoginName);
request.setCreatorName(senderName);
PortalTodoResponse result = portalTodoService.sendTodo(request);
```
审核完成时调用 `portalTodoService.doneTodo(request)`;撤销或删除时调用 `portalTodoService.removeTodo(request)`。这两个操作也可单独构造 `PortalTodoRequest`,只设置四项定位数据,不需要标题和内容。
- `success=true`:门户接口返回成功,实际展示仍以门户为准。
- `skipped=true`:开发模式或开关关闭,没有发送请求。
- 两者均为 `false`:校验、网络或门户业务失败,使用 `msg``code``traceId` 排查。
- `data` 按门户原值返回,不当作 `notifyId` 使用。
## 启用配置
1. 部署配置设置 `portal.todo.base-url``portal.todo.username``portal.todo.password`。密码按原始字符填写,不编码、不转义增删。
2. 按当前项目约定,`Globals.sso` 必须为 `true`(来自 `cas.enable`)。
3. 在现有系统参数管理中配置 `AppPortalTodo=true` 并刷新系统配置;未配置默认关闭,与 `AppSms` 无关。
4. 设置正确的项目 `AppDomain`,供 wf 构建审核链接。
wf 会自动同步通用任务变化,业务代码无需再次发送同一 wf 待办。HTTP 超时不自动重试;本地事务无法撤销已成功的外部调用,需要根据日志人工补偿。
PC链接使用任务的 `formKey`,手机链接使用 `h5FormKey`,补齐 `AppDomain` 并携带编码后的 `taskId``bizId``taskKey`。未配置手机表单时记录提示并回退到 `/platform/h5` 手机首页。独立调用的 `mobileLink` 应传完整HTTP(S)地址,未传也回退到项目手机首页。修改只影响新发送的待办,不更新门户已有链接。
## 发送记录与控制台排查
成功的 `send` 请求写入 `portal_todo_log`,每次请求一条,保存实际发送的完整JSON、原始响应正文、业务标识、标题正文、发送人、接收人账号数组、链接、请求时间、耗时及返回状态。失败、跳过、已办和删除不写成功发送记录。
控制台搜索 `——————————————学校OA待办发送`,按“调用编号”关联“请求开始”和“请求结束”。结束日志包含成功/失败/跳过、HTTP状态、业务码、提示、traceId、耗时和响应正文;无响应的网络异常仍输出结束记录。日志不打印Basic凭证或认证头。
成功发送但保存记录失败时另输出“发送成功但记录入库失败”,门户返回结果保持成功,不自动重发。保存沿用业务事务,外层回滚时记录也可能回滚;控制台“已写入当前事务”不表示外层事务已提交。
表结构通过现有实体扫描机制管理:`MainLauncher.init_sys` 仅在DEBUG日志级别时自动建表。生产部署需确认该表已创建,本功能不生成或执行额外SQL,不提供后台查询页面。
## 验证说明
`PortalTodoServiceTest` 使用本地模拟 HTTP 服务;`FlowPortalTodoServiceTest` 使用内存 Dao 替身。两者不连接真实门户或业务数据库。
串并行会签、转办、撤回和流程删除仍需在测试部署中走完整业务流程验收。提案附议等脱离标准任务状态的业务特例未单独适配。
现有 `activate` 方法只恢复 `INTERRUPT` 状态任务,并不恢复 `PENDING`;本次保持该业务规则,仅为实际恢复的任务重发待办。
@@ -0,0 +1,56 @@
package com.budwk.app.base.portal.impl;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.portal.PortalTodoLogService;
import com.budwk.app.base.portal.model.PortalTodoLog;
import com.budwk.app.base.portal.model.PortalTodoResponse;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
/** 记录实际请求报文的业务快照,不读取可变化的人员或流程数据。 */
@IocBean
public class PortalTodoLogServiceImpl implements PortalTodoLogService {
@Inject
private Dao dao;
@Override
@Aop(TransAop.READ_COMMITTED)
public void recordSuccess(String requestId, String requestUrl, String requestBody, String responseBody,
PortalTodoResponse response, long requestTime, long elapsedMillis) {
// 防止跳过或HTTP失败被误记为成功发送;多接收人作为一次请求保留原数组。
if (response == null || !response.isSuccess() || response.isSkipped()
|| response.getHttpStatus() == null || response.getHttpStatus() < 200
|| response.getHttpStatus() >= 300) return;
JSONObject payload = JSONUtil.parseObj(requestBody);
PortalTodoLog record = new PortalTodoLog();
record.setId(requestId);
record.setAppName(payload.getStr("appName"));
record.setModuleName(payload.getStr("moduleName"));
record.setEntityName(payload.getStr("entityName"));
record.setEntityId(payload.getStr("entityId"));
record.setEntityNumber(payload.getStr("entityNumber"));
record.setSubject(payload.getStr("subject"));
record.setContent(payload.getStr("content"));
record.setCreator(payload.getStr("creator"));
record.setCreatorName(payload.getStr("creatorName"));
record.setTargets(JSONUtil.toJsonStr(payload.getJSONArray("targets")));
record.setLink(payload.getStr("link"));
record.setMobileLink(payload.getStr("mobileLink"));
record.setRequestUrl(requestUrl);
record.setRequestBody(requestBody);
record.setResponseBody(responseBody);
record.setRequestTime(requestTime);
record.setElapsedMillis(elapsedMillis);
record.setHttpStatus(response.getHttpStatus());
record.setSuccess(response.isSuccess());
record.setCode(response.getCode());
record.setMsg(response.getMsg());
record.setData(response.getData());
record.setTraceId(response.getTraceId());
dao.insert(record);
}
}
@@ -0,0 +1,193 @@
package com.budwk.app.base.portal.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.portal.PortalTodoService;
import com.budwk.app.base.portal.PortalTodoLogService;
import com.budwk.app.base.portal.model.*;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.impl.PropertiesProxy;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/** Basic 认证的门户 REST 客户端,所有操作共用环境拦截及结果处理。 */
@IocBean
@Slf4j
public class PortalTodoServiceImpl implements PortalTodoService {
private static final String LOG_PREFIX = "——————————————学校OA待办发送";
@Inject
private PropertiesProxy conf;
@Inject
private PortalTodoLogService portalTodoLogService;
@Override
@Aop(TransAop.READ_COMMITTED)
public PortalTodoResponse sendTodo(PortalTodoSendRequest request) {
return post("send", request);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public PortalTodoResponse doneTodo(PortalTodoRequest request) {
return post("done", request);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public PortalTodoResponse removeTodo(PortalTodoRequest request) {
return post("removeTodo", request);
}
/** 先拦截环境与开关,再校验参数并请求;禁止自动重试不确定是否已送达的发送。 */
private PortalTodoResponse post(String operation, PortalTodoRequest request) {
PortalTodoResponse result = new PortalTodoResponse();
String requestId = UUID.randomUUID().toString().replace("-", "");
long requestTime = System.currentTimeMillis();
long startedNanos = System.nanoTime();
String requestUrl = null;
String requestBody = null;
String responseBody = null;
String exceptionType = null;
HttpURLConnection connection = null;
try {
// 环境与功能开关均开启才调用门户,拦截早于参数及认证配置校验。
if (!Globals.sso || !Globals.MyConfig.getBoolean("AppPortalTodo", false)) {
result.setSkipped(true);
result.setCode("SKIPPED");
result.setMsg(!Globals.sso ? "开发模式不调用门户待办" : "门户待办开关未开启");
return result;
}
JSONObject payload = payload(operation, request);
requestBody = payload.toString();
String baseUrl = required(conf.get("portal.todo.base-url"), "门户地址未配置");
String username = required(conf.get("portal.todo.username"), "门户认证用户名未配置");
String password = required(conf.get("portal.todo.password"), "门户认证密码未配置");
requestUrl = StrUtil.removeSuffix(baseUrl, "/")
+ "/openapi/sys-notifybus/sysNotifyComponent/" + operation;
// 打印实际发送的同一份JSON,不输出Basic用户名、密码或Authorization头。
log.info("{}【请求开始】调用编号={},操作={},地址={},发送内容={}",
LOG_PREFIX, requestId, operation, requestUrl, requestBody);
connection = (HttpURLConnection) URI.create(requestUrl).toURL().openConnection();
connection.setInstanceFollowRedirects(false);
connection.setRequestMethod("POST");
connection.setDoOutput(true);
connection.setUseCaches(false);
connection.setConnectTimeout(conf.getInt("portal.todo.connect-timeout", 5000));
connection.setReadTimeout(conf.getInt("portal.todo.read-timeout", 10000));
connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
connection.setRequestProperty("Authorization", "Basic " + Base64.getEncoder()
.encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)));
try (OutputStream out = connection.getOutputStream()) {
out.write(requestBody.getBytes(StandardCharsets.UTF_8));
}
int status = connection.getResponseCode();
result.setHttpStatus(status);
try (InputStream in = status >= 400 ? connection.getErrorStream() : connection.getInputStream()) {
// 先保留原始响应,非JSON、解析失败等情况也能在控制台排查。
responseBody = in == null ? "" : new String(in.readAllBytes(), StandardCharsets.UTF_8);
JSONObject json = JSONUtil.parseObj(responseBody);
result.setCode(json.getStr("code"));
result.setMsg(json.getStr("msg"));
result.setData(json.getStr("data"));
result.setTraceId(json.getStr("traceId"));
result.setSuccess(status >= 200 && status < 300 && Boolean.TRUE.equals(json.getBool("success")));
}
} catch (Exception e) {
result.setSuccess(false);
result.setCode("CLIENT_ERROR");
exceptionType = e.getClass().getSimpleName();
// 异常仅记录类型,不打印可能携带认证信息的异常原文。
result.setMsg(e instanceof IllegalArgumentException ? "门户参数、配置或响应格式无效" : "门户请求失败或超时");
} finally {
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
// 将多行响应编码为单行JSON字符串,确保每条日志均可用统一前缀定位。
String outcome = result.isSkipped() ? "已跳过" : result.isSuccess() ? "成功" : "失败";
String format = "{}【请求结束-{}】调用编号={},操作={},业务ID={}HTTP状态={},耗时={}mscode={}msg={}traceId={},异常类型={},返回内容={}";
Object[] values = {LOG_PREFIX, outcome, requestId, operation,
request == null ? null : request.getEntityId(), result.getHttpStatus(), elapsedMillis,
result.getCode(), JSONUtil.toJsonStr(result.getMsg()), result.getTraceId(), exceptionType,
JSONUtil.toJsonStr(responseBody)};
if (result.isSuccess() || result.isSkipped()) log.info(format, values);
else log.error(format, values);
if (connection != null) connection.disconnect();
// 仅send成功落库。日志保存异常不能改写门户成功结果,也不能触发再次发送。
if ("send".equals(operation) && result.isSuccess() && !result.isSkipped()) {
try {
portalTodoLogService.recordSuccess(requestId, requestUrl, requestBody, responseBody,
result, requestTime, elapsedMillis);
log.info("{}【记录已写入当前事务】调用编号={},traceId={}", LOG_PREFIX, requestId, result.getTraceId());
} catch (Exception e) {
log.error("{}【发送成功但记录入库失败】调用编号={},业务ID={}traceId={},异常类型={}",
LOG_PREFIX, requestId, request.getEntityId(), result.getTraceId(), e.getClass().getSimpleName());
}
}
}
return result;
}
/** 只发送接口使用的字段;删除和已办不得混入标题、正文等发送参数。 */
private JSONObject payload(String operation, PortalTodoRequest request) {
if (request == null) throw new IllegalArgumentException("请求不能为空");
if (request.getTargets() == null || request.getTargets().isEmpty()
|| request.getTargets().stream().anyMatch(StrUtil::isBlank)) {
throw new IllegalArgumentException("接收人账号不能为空");
}
JSONObject json = new JSONObject();
json.set("appName", "智慧工会平台");
json.set("moduleName", "智慧工会平台");
json.set("entityName", required(request.getEntityName(), "业务名称不能为空"));
json.set("entityId", required(request.getEntityId(), "业务ID不能为空"));
json.set("entityNumber", required(request.getEntityNumber(), "单据编码不能为空"));
json.set("targets", request.getTargets().stream().distinct().toList());
json.set("notifyType", "todo");
json.set("orgProperty", "fdLoginName");
json.set("todoType", 1);
json.set("async", false);
if ("send".equals(operation)) {
PortalTodoSendRequest send = (PortalTodoSendRequest) request;
json.set("subject", required(send.getSubject(), "标题不能为空"));
json.set("content", required(send.getContent(), "正文不能为空"));
String link = required(send.getLink(), "PC链接不能为空");
URI uri = URI.create(link);
if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null) throw new IllegalArgumentException("PC链接必须为完整HTTP地址");
json.set("link", link);
// 独立调用可传完整H5地址;未提供时使用手机首页,避免门户打开空白页。
String mobileLink = send.getMobileLink();
if (StrUtil.isBlank(mobileLink)) {
mobileLink = URI.create(StrUtil.appendIfMissing(Globals.AppDomain, "/"))
.resolve("/platform/h5").toString();
}
URI mobileUri = URI.create(mobileLink);
if (!("http".equalsIgnoreCase(mobileUri.getScheme()) || "https".equalsIgnoreCase(mobileUri.getScheme()))
|| mobileUri.getHost() == null) throw new IllegalArgumentException("手机链接必须为完整HTTP地址");
json.set("mobileLink", mobileLink);
if (StrUtil.isNotBlank(send.getCreator())) json.set("creator", send.getCreator());
if (StrUtil.isNotBlank(send.getCreatorName())) json.set("creatorName", send.getCreatorName());
json.set("queryType", 1);
json.set("todoLevel", 3);
}
return json;
}
private String required(String value, String message) {
if (StrUtil.isBlank(value)) throw new IllegalArgumentException(message);
return value;
}
}
@@ -0,0 +1,99 @@
package com.budwk.app.base.portal.model;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.*;
/** 每次发送成功的请求保存一条快照,多个接收人仍归属于同一次请求。 */
@Data
@EqualsAndHashCode(callSuper = true)
@Table("portal_todo_log")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("学校OA待办成功发送记录")
public class PortalTodoLog extends BaseModel {
@Name
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("请求关联编号,与控制台调用编号一致")
private String id;
@Column
@Comment("来源系统")
private String appName;
@Column
@Comment("来源模块")
private String moduleName;
@Column
@Comment("来源业务名称")
private String entityName;
@Column
@Comment("业务记录或wf任务ID")
private String entityId;
@Column
@Comment("业务单据编码")
private String entityNumber;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("待办标题")
private String subject;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("待办正文")
private String content;
@Column
@Comment("发送人登录账号")
private String creator;
@Column
@Comment("发送人姓名")
private String creatorName;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("接收人登录账号JSON数组,保留工号前导零")
private String targets;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("PC跳转地址")
private String link;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("移动端跳转地址")
private String mobileLink;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("发送接口地址,不含认证信息")
private String requestUrl;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("实际发送的完整JSON报文")
private String requestBody;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("门户完整响应正文")
private String responseBody;
@Column
@Comment("请求开始时间,毫秒时间戳")
private Long requestTime;
@Column
@Comment("接口调用耗时,毫秒,不含日志入库")
private Long elapsedMillis;
@Column
@Comment("HTTP响应状态码")
private Integer httpStatus;
@Column
@Comment("门户是否返回成功,不代表接收人已查看")
private Boolean success;
@Column
@Comment("门户业务状态码")
private String code;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("门户返回提示")
private String msg;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("门户原样返回的数据,不推断为notifyId")
private String data;
@Column
@Comment("门户链路追踪编号")
private String traceId;
}
@@ -0,0 +1,17 @@
package com.budwk.app.base.portal.model;
import lombok.Data;
import java.util.List;
/** 门户待办定位参数;发送、已办和删除时必须使用同一组业务标识及人员账号。 */
@Data
public class PortalTodoRequest {
/** 来源业务名称,同一待办生命周期内不得更改。 */
private String entityName;
/** 业务记录或任务的唯一 ID,不能仅使用可重复的节点名称。 */
private String entityId;
/** 所属业务单据编码。 */
private String entityNumber;
/** 门户登录账号数组,保留工号前导零,不传本系统人员 ID。 */
private List<String> targets;
}
@@ -0,0 +1,18 @@
package com.budwk.app.base.portal.model;
import lombok.Data;
/** 门户调用结果;跳过不表示发送成功,网络异常时 httpStatus 为空。 */
@Data
public class PortalTodoResponse {
private boolean success;
private boolean skipped;
private Integer httpStatus;
/** 门户业务错误码,或本地 SKIPPED / CLIENT_ERROR。 */
private String code;
private String msg;
/** 门户原样返回的数据,不推断为 notifyId。 */
private String data;
/** 提供给门户管理员排查的链路标识。 */
private String traceId;
}
@@ -0,0 +1,22 @@
package com.budwk.app.base.portal.model;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 发送待办参数;继承的定位参数需由业务方保留,供后续已办、删除使用。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class PortalTodoSendRequest extends PortalTodoRequest {
/** 待办标题。 */
private String subject;
/** 待办正文。 */
private String content;
/** 发送人门户登录账号,可不填。 */
private String creator;
/** 发送人姓名,可不填。 */
private String creatorName;
/** 可访问的 PC 业务页面完整地址,必填。 */
private String link;
/** 可访问的H5业务页面完整HTTP(S)地址;未传时回退到项目手机首页。 */
private String mobileLink;
}
@@ -0,0 +1,66 @@
package com.budwk.app.flow.listenter;
import com.budwk.app.flow.engine.event.ProcessEvent;
import com.budwk.app.flow.engine.event.ProcessEventListener;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.FlowPortalTodoService;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
/** 门户专用监听器;发送从实际参与人新增入口触发,不重复消费任务开始事件。 */
@IocBean
@Slf4j
public class ProcessPortalTodoEventListener implements ProcessEventListener {
private static final String LOG_PREFIX = "——————————————学校OA待办发送";
@Inject
private Dao dao;
@Inject
private FlowPortalTodoService flowPortalTodoService;
/** @param event 任务结束/撤销或实例结束事件;按真实状态区分已办和移除,异常不阻断流程。 */
@Override
public void onEvent(ProcessEvent event) {
log.info("{}【WF事件入口】事件={},来源ID={}", LOG_PREFIX, event.getEventType(), event.getSourceId());
// 未启用门户同步时停止消费事件,保留日志便于定位跳过原因。
if (!Globals.sso || !Globals.MyConfig.getBoolean("AppPortalTodo", false)) {
log.info("{}【WF事件跳过】事件={},来源ID={},原因={}sso={}AppPortalTodo={}",
LOG_PREFIX, event.getEventType(), event.getSourceId(),
!Globals.sso ? "开发模式" : "门户待办开关未开启", Globals.sso,
Globals.MyConfig.getBoolean("AppPortalTodo", false));
return;
}
try {
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_END) {
ProcessTask task = dao.fetch(ProcessTask.class, event.getSourceId());
if (task == null) {
log.warn("{}【WF事件跳过】来源ID={},原因=任务不存在", LOG_PREFIX, event.getSourceId());
return;
}
if (ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) {
flowPortalTodoService.doneTask(task.getId());
} else if (ProcessTaskStateEnum.ABANDON.getCode().equals(task.getTaskState())) {
flowPortalTodoService.removeTask(task.getId(), null);
} else {
log.info("{}【WF事件跳过】任务ID={},原因=结束事件的任务状态无需同步,任务状态={}",
LOG_PREFIX, task.getId(), task.getTaskState());
}
} else if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_REVOKE) {
flowPortalTodoService.removeTask(event.getSourceId(), null);
} else if (event.getEventType() == ProcessEventTypeEnum.PROCESS_INSTANCE_END) {
flowPortalTodoService.removeInstance(event.getSourceId());
} else {
// 开始事件不再次发送,发送已由新增参与人入口负责。
log.info("{}【WF事件跳过】事件={},来源ID={},原因=该事件无需本监听器同步,发送由新增参与人入口负责",
LOG_PREFIX, event.getEventType(), event.getSourceId());
}
} catch (Exception e) {
log.warn("{}【WF事件同步失败】来源ID={},异常类型={}", LOG_PREFIX, event.getSourceId(), e.getClass().getSimpleName());
}
}
}
@@ -35,6 +35,9 @@ public class FlowCommonService {
@Inject @Inject
private Dao dao; private Dao dao;
@Inject
private FlowPortalTodoService flowPortalTodoService;
/** /**
* 执行任务 * 执行任务
* @param args * @param args
@@ -111,9 +114,12 @@ public class FlowCommonService {
// 撤销任务 // 撤销任务
List<ProcessTask> taskList = dao.query(ProcessTask.class, Cnd.where(ProcessTask::getTaskParentId, "=", taskId)); List<ProcessTask> taskList = dao.query(ProcessTask.class, Cnd.where(ProcessTask::getTaskParentId, "=", taskId));
// 收集全部被撤回的任务,包含并行会签的非直接子任务,避免门户残留待办。
java.util.Set<Long> revokedTaskIds = new java.util.LinkedHashSet<>();
for (ProcessTask task : taskList) { for (ProcessTask task : taskList) {
task.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode()); task.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode());
dao.update(task); dao.update(task);
revokedTaskIds.add(task.getId());
} }
// 会签并行任务 撤销后续任务 // 会签并行任务 撤销后续任务
if (selfTask.getPerformType().equals(ProcessTaskPerformTypeEnum.COUNTERSIGN.getCode())) { if (selfTask.getPerformType().equals(ProcessTaskPerformTypeEnum.COUNTERSIGN.getCode())) {
@@ -122,6 +128,7 @@ public class FlowCommonService {
for (ProcessTask doingTask : doingTasks) { for (ProcessTask doingTask : doingTasks) {
doingTask.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode()); doingTask.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode());
dao.update(doingTask); dao.update(doingTask);
revokedTaskIds.add(doingTask.getId());
} }
} }
@@ -136,6 +143,12 @@ public class FlowCommonService {
ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_REVOKE).sourceId(task.getId()).build()); ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_REVOKE).sourceId(task.getId()).build());
} }
// 直接子任务由撤回事件同步;额外并行任务在此补齐,不重复删除同一任务。
taskList.forEach(task -> revokedTaskIds.remove(task.getId()));
revokedTaskIds.forEach(id -> flowPortalTodoService.removeTask(id, null));
// 原任务重新进入办理状态,需要恢复它在门户中的待办。
flowPortalTodoService.sendTask(selfTask.getId(), null);
return Result.success(); return Result.success();
} }
@@ -0,0 +1,213 @@
package com.budwk.app.flow.service;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.portal.PortalTodoService;
import com.budwk.app.base.portal.model.PortalTodoSendRequest;
import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* wf 门户适配,所有标识由任务持久化数据重建,不依赖当前登录请求。
*/
@IocBean
@Slf4j
public class FlowPortalTodoService {
private static final String LOG_PREFIX = "——————————————学校OA待办发送";
@Inject
private Dao dao;
@Inject
private PortalTodoService portalTodoService;
/**
* @param taskId wf 任务 ID
* @param actors 本次新增的参与人,null 表示当前任务全部参与人;空列表不发送
* @return 无返回值;门户异常只记录,不中断工作流
*/
@Aop(TransAop.READ_COMMITTED)
public void sendTask(Long taskId, List<ProcessTaskActor> actors) {
sync(taskId, actors, "send");
}
/**
* @param taskId 已完成的 wf 任务 ID;参与人账号沿用原任务,异常只记录。
*/
@Aop(TransAop.READ_COMMITTED)
public void doneTask(Long taskId) {
sync(taskId, null, "done");
}
/**
* @param taskId 原 wf 任务 ID,必须在删除任务记录之前调用
* @param actors 被移除的参与人快照;null 表示原任务全部参与人
* @return 无返回值;不改变 wf 数据,门户失败仅记录
*/
@Aop(TransAop.READ_COMMITTED)
public void removeTask(Long taskId, List<ProcessTaskActor> actors) {
sync(taskId, actors, "removeTodo");
}
/**
* @param instanceId wf 实例 ID;实例结束或删除前清理非已完成任务的门户待办。
*/
@Aop(TransAop.READ_COMMITTED)
public void removeInstance(Long instanceId) {
log.info("{}【WF实例清理入口】实例ID={}", LOG_PREFIX, instanceId);
if (!enabled()) {
log.info("{}【WF跳过】实例ID={},原因={}sso={}AppPortalTodo={}", LOG_PREFIX, instanceId,
!Globals.sso ? "开发模式" : "门户待办开关未开启", Globals.sso,
Globals.MyConfig.getBoolean("AppPortalTodo", false));
return;
}
try {
List<ProcessTask> tasks = dao.query(ProcessTask.class,
Cnd.where(ProcessTask::getProcessInstanceId, "=", instanceId)
.and(ProcessTask::getTaskState, "!=", ProcessTaskStateEnum.FINISHED.getCode()));
if (tasks.isEmpty()) {
log.info("{}【WF跳过】实例ID={},原因=没有需要清理的任务", LOG_PREFIX, instanceId);
}
for (ProcessTask task : tasks) sync(task.getId(), null, "removeTodo");
} catch (Exception e) {
log.warn("{}【WF实例清理失败】实例ID={},异常类型={}", LOG_PREFIX, instanceId, e.getClass().getSimpleName());
}
}
private boolean enabled() {
// WF同步与独立门户调用使用相同的环境及功能开关。
return Globals.sso && Globals.MyConfig.getBoolean("AppPortalTodo", false);
}
/**
* 包括数据读取和参数组装在内的异常均隔离,避免影响已授权的 wf 业务操作。
*/
private void sync(Long taskId, List<ProcessTaskActor> actors, String operation) {
// 在开关判断之前记录入口,使未进入HTTP客户端的调用也能明确定位跳过原因。
log.info("{}【WF同步入口】任务ID={},操作={}", LOG_PREFIX, taskId, operation);
if (!enabled()) {
log.info("{}【WF跳过】任务ID={},操作={},原因={}sso={}AppPortalTodo={}",
LOG_PREFIX, taskId, operation, !Globals.sso ? "开发模式" : "门户待办开关未开启",
Globals.sso, Globals.MyConfig.getBoolean("AppPortalTodo", false));
return;
}
try {
ProcessTask task = dao.fetch(ProcessTask.class, taskId);
if (task == null) {
log.warn("{}【WF跳过】任务ID={},操作={},原因=任务不存在", LOG_PREFIX, taskId, operation);
return;
}
// 首次申请节点由发起人直接提交,无需发送瞬时待办;退回申请节点仍需发送。
if (Long.valueOf(0).equals(task.getTaskParentId()) && "startTask".equals(task.getTaskName())) {
log.info("{}【WF跳过】任务ID={},操作={},原因=首次申请节点无需发送待办", LOG_PREFIX, taskId, operation);
return;
}
if ("send".equals(operation) && !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) {
log.info("{}【WF跳过】任务ID={},操作={},原因=任务非办理状态,任务状态={}",
LOG_PREFIX, taskId, operation, task.getTaskState());
return;
}
ProcessInstance instance = dao.fetch(ProcessInstance.class, task.getProcessInstanceId());
if (instance == null) {
log.warn("{}【WF跳过】任务ID={},操作={},实例ID={},原因=流程实例不存在",
LOG_PREFIX, taskId, operation, task.getProcessInstanceId());
return;
}
List<ProcessTaskActor> recipients = actors == null ? dao.query(ProcessTaskActor.class,
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", taskId)) : actors;
if (recipients.isEmpty()) {
log.info("{}【WF跳过】任务ID={},操作={},原因=接收人列表为空", LOG_PREFIX, taskId, operation);
return;
}
if (recipients.stream().anyMatch(actor -> StrUtil.isBlank(actor.getActorAccount()))) {
log.warn("{}【WF账号缺失】任务ID={},操作={},部分参与人没有登录账号,将过滤这些人员",
LOG_PREFIX, taskId, operation);
}
List<String> accounts = recipients.stream().map(ProcessTaskActor::getActorAccount)
.filter(StrUtil::isNotBlank).distinct().toList();
if (accounts.isEmpty()) {
log.info("{}【WF跳过】任务ID={},操作={},原因=接收人均缺少有效登录账号", LOG_PREFIX, taskId, operation);
return;
}
PortalTodoSendRequest request = new PortalTodoSendRequest();
request.setEntityName("wf");
request.setEntityId(String.valueOf(taskId));
request.setEntityNumber(StrUtil.blankToDefault(instance.getBusinessNo(), String.valueOf(instance.getId())));
request.setTargets(accounts);
if ("send".equals(operation)) {
String name = StrUtil.isBlank(instance.getVariable()) ? null : JSONUtil.parseObj(instance.getVariable())
.getStr(FlowConst.PROCESS_INSTANCE_NAME);
name = StrUtil.blankToDefault(name, request.getEntityNumber());
String node = StrUtil.blankToDefault(task.getDisplayName(), task.getTaskName());
request.setSubject(name + "-" + node);
request.setContent("您有一条待办任务,实例:" + name + ",处理环节:" + node);
request.setLink(buildLink(task, instance));
// H5使用任务自身的手机表单,不使用PC表单替代;缺失配置时明确记录回退原因。
if (StrUtil.isBlank(task.getH5FormKey())) {
log.warn("{}【H5入口回退】任务ID={},原因=未配置h5FormKey,将跳转手机首页", LOG_PREFIX, taskId);
}
request.setMobileLink(buildFormLink(task, instance, task.getH5FormKey(), "/platform/h5"));
if (StrUtil.isNotBlank(instance.getOperator())) {
View_user creator = dao.fetch(View_user.class, Cnd.where("id", "=", instance.getOperator()));
if (creator != null) {
request.setCreator(creator.getLoginname());
request.setCreatorName(creator.getUsername());
}
}
portalTodoService.sendTodo(request);
} else if ("done".equals(operation)) {
portalTodoService.doneTodo(request);
} else {
portalTodoService.removeTodo(request);
}
} catch (Exception e) {
log.warn("{}【WF同步失败】任务ID={},操作={},异常类型={}", LOG_PREFIX, taskId, operation, e.getClass().getSimpleName());
}
}
/**
* 复用 wf 表单入口参数,保留原链接查询和片段,避免工号或业务编号中的特殊字符破坏地址。
*/
private String buildLink(ProcessTask task, ProcessInstance instance) {
return buildFormLink(task, instance, task.getFormKey(), "/flow/todoCenter");
}
/**
* @param task 提供taskId、taskKey的WF任务
* @param instance 提供bizIdbusinessNo)的流程实例
* @param formKey 对应端的业务表单地址,支持相对路径及完整HTTP(S)地址
* @param fallback 表单未配置时的入口路径;回退入口不附加业务参数
* @return 补齐项目域名并编码任务参数的地址,保留已有查询参数和锚点
*/
private String buildFormLink(ProcessTask task, ProcessInstance instance, String formKey, String fallback) {
String form = StrUtil.blankToDefault(formKey, fallback);
String link = URI.create(StrUtil.appendIfMissing(Globals.AppDomain, "/")).resolve(form).toString();
if (StrUtil.isBlank(formKey)) return link;
int fragmentAt = link.indexOf('#');
String fragment = fragmentAt < 0 ? "" : link.substring(fragmentAt);
if (fragmentAt >= 0) link = link.substring(0, fragmentAt);
return link + (link.contains("?") ? "&" : "?") + "taskId=" + task.getId()
+ "&bizId=" + encode(StrUtil.blankToDefault(instance.getBusinessNo(), ""))
+ "&taskKey=" + encode(task.getTaskName()) + fragment;
}
private String encode(String value) {
return URLEncoder.encode(StrUtil.nullToEmpty(value), StandardCharsets.UTF_8);
}
}
@@ -28,6 +28,7 @@ import com.budwk.app.flow.enums.ProcessInstanceStateEnum;
import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; import com.budwk.app.flow.enums.ProcessSubmitTypeEnum;
import com.budwk.app.flow.enums.ProcessTaskStateEnum; import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.ProcessDefineService; import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.service.FlowPortalTodoService;
import com.budwk.app.flow.service.ProcessInstanceService; import com.budwk.app.flow.service.ProcessInstanceService;
import com.budwk.app.flow.service.ProcessTaskService; import com.budwk.app.flow.service.ProcessTaskService;
import com.budwk.app.flow.vo.HighLightVO; import com.budwk.app.flow.vo.HighLightVO;
@@ -57,6 +58,9 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
@Inject @Inject
private ProcessDefineService processDefineService; private ProcessDefineService processDefineService;
@Inject
private FlowPortalTodoService flowPortalTodoService;
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public boolean save(ProcessInstance param) { public boolean save(ProcessInstance param) {
@@ -198,10 +202,16 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processInstance.setState(ProcessInstanceStateEnum.INTERRUPT.getCode()); processInstance.setState(ProcessInstanceStateEnum.INTERRUPT.getCode());
dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId)
.and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode())); .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode()));
// 终止后任务不可办理,同步清理门户待办。
flowPortalTodoService.removeInstance(processInstanceId);
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void resume(Long processInstanceId, String operator) { public void resume(Long processInstanceId, String operator) {
List<ProcessTask> restoredTasks = dao().query(ProcessTask.class,
Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
Date now = new Date(); Date now = new Date();
ProcessInstance processInstance = fetch(processInstanceId); ProcessInstance processInstance = fetch(processInstanceId);
// 1. 更新流程实例状态为进行中 // 1. 更新流程实例状态为进行中
@@ -212,9 +222,12 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode()); processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode());
dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
// 仅恢复本次由终止状态重新进入办理的任务,避免重复发送已有待办。
restoredTasks.forEach(task -> flowPortalTodoService.sendTask(task.getId(), null));
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void pending(Long processInstanceId, String operator) { public void pending(Long processInstanceId, String operator) {
Date now = new Date(); Date now = new Date();
// 1. 将该流程实例产生的任务状态修改为挂起 // 1. 将该流程实例产生的任务状态修改为挂起
@@ -227,10 +240,17 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processInstance.setState(ProcessInstanceStateEnum.PENDING.getCode()); processInstance.setState(ProcessInstanceStateEnum.PENDING.getCode());
dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId)
.and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode())); .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode()));
// 挂起期间不应继续展示可办理的门户待办。
flowPortalTodoService.removeInstance(processInstanceId);
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void activate(Long processInstanceId, String operator) { public void activate(Long processInstanceId, String operator) {
// 沿用当前 activate 的状态选择规则,只同步实际被恢复的任务。
List<ProcessTask> restoredTasks = dao().query(ProcessTask.class,
Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
ProcessInstance processInstance = fetch(processInstanceId); ProcessInstance processInstance = fetch(processInstanceId);
// 1. 更新流程实例状态为进行中 // 1. 更新流程实例状态为进行中
processInstance.setState(ProcessInstanceStateEnum.DOING.getCode()); processInstance.setState(ProcessInstanceStateEnum.DOING.getCode());
@@ -240,6 +260,7 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode()); processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode());
dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
restoredTasks.forEach(task -> flowPortalTodoService.sendTask(task.getId(), null));
} }
@Override @Override
@@ -323,6 +344,7 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void withdraw(Long processInstanceId, String operator) { public void withdraw(Long processInstanceId, String operator) {
// Date now = new Date(); // Date now = new Date();
// 1. 将该流程实例状态修改为撤回 // 1. 将该流程实例状态修改为撤回
@@ -342,6 +364,8 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processTask.setUpdatedBy(operator); processTask.setUpdatedBy(operator);
} }
dao().update(taskList, "taskState|updateAt|updateBy"); dao().update(taskList, "taskState|updateAt|updateBy");
// 实例撤回没有任务撤回事件,在此同步删除已不可办理的门户待办。
flowPortalTodoService.removeInstance(processInstanceId);
} }
// int update = dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) // int update = dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId)
@@ -555,6 +579,8 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void deleteProcessInstanceById(Long processInstanceId) { public void deleteProcessInstanceById(Long processInstanceId) {
// 在实例、任务和参与人删除前清理门户,否则无法重建原待办定位参数。
flowPortalTodoService.removeInstance(processInstanceId);
List<ProcessTask> taskList = dao().query(ProcessTask.class, Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)); List<ProcessTask> taskList = dao().query(ProcessTask.class, Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId));
List<Long> taskIds = taskList.stream().map(ProcessTask::getId).toList(); List<Long> taskIds = taskList.stream().map(ProcessTask::getId).toList();
// 任务参与人依赖流程任务,删除流程时先清理参与人,避免遗留无法回查所属流程的孤立记录。 // 任务参与人依赖流程任务,删除流程时先清理参与人,避免遗留无法回查所属流程的孤立记录。
@@ -27,6 +27,7 @@ import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.entity.ProcessTaskActor; import com.budwk.app.flow.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.*; import com.budwk.app.flow.enums.*;
import com.budwk.app.flow.service.ProcessDefineService; import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.service.FlowPortalTodoService;
import com.budwk.app.flow.service.ProcessInstanceService; import com.budwk.app.flow.service.ProcessInstanceService;
import com.budwk.app.flow.service.ProcessTaskService; import com.budwk.app.flow.service.ProcessTaskService;
import com.budwk.app.flow.vo.ProcessTaskVO; import com.budwk.app.flow.vo.ProcessTaskVO;
@@ -56,6 +57,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
@Inject @Inject
private ProcessDefineService processDefineService; private ProcessDefineService processDefineService;
@Inject
private FlowPortalTodoService flowPortalTodoService;
@Override @Override
public boolean save(ProcessTask param) { public boolean save(ProcessTask param) {
param.setId(null); param.setId(null);
@@ -125,6 +129,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void finishProcessTask(Long processTaskId, String operator, Dict args) { public void finishProcessTask(Long processTaskId, String operator, Dict args) {
ProcessTask his = fetch(processTaskId); ProcessTask his = fetch(processTaskId);
ProcessTask processTask = new ProcessTask(); ProcessTask processTask = new ProcessTask();
@@ -149,6 +154,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void abandonProcessTask(Long processTaskId, String operator, Dict args) { public void abandonProcessTask(Long processTaskId, String operator, Dict args) {
ProcessTask his = fetch(processTaskId); ProcessTask his = fetch(processTaskId);
ProcessTask processTask = new ProcessTask(); ProcessTask processTask = new ProcessTask();
@@ -228,10 +234,13 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
if (CollectionUtil.isEmpty(actors)) return; if (CollectionUtil.isEmpty(actors)) return;
List<String> dbActors = getTaskActors(processTaskId); List<String> dbActors = getTaskActors(processTaskId);
List<String> newActors = actors.stream().filter(actor -> !dbActors.contains(actor)).toList(); List<String> newActors = actors.stream().filter(actor -> !dbActors.contains(actor)).toList();
if (newActors.isEmpty()) return;
Sql sql = Sqls.create("select id,username,loginname,unitName,unitId from vw_user where id in (@ids)"); Sql sql = Sqls.create("select id,username,loginname,unitName,unitId from vw_user where id in (@ids)");
sql.setParam("ids", newActors); sql.setParam("ids", newActors);
List<NutMap> list = listMap(sql); List<NutMap> list = listMap(sql);
List<ProcessTaskActor> addedActors = new ArrayList<>();
for (NutMap actor : list) { for (NutMap actor : list) {
ProcessTaskActor processTaskActor = new ProcessTaskActor(); ProcessTaskActor processTaskActor = new ProcessTaskActor();
processTaskActor.setProcessTaskId(processTaskId); processTaskActor.setProcessTaskId(processTaskId);
@@ -243,7 +252,10 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
processTaskActor.setCreatedAt(System.currentTimeMillis()); processTaskActor.setCreatedAt(System.currentTimeMillis());
System.out.println(StrUtil.format("给任务:{},添加参与者:{}", processTaskId, actor.toString())); System.out.println(StrUtil.format("给任务:{},添加参与者:{}", processTaskId, actor.toString()));
insert(processTaskActor); insert(processTaskActor);
addedActors.add(processTaskActor);
} }
// 只为实际新增的账号发送,覆盖普通任务、串并行会签、转办和加签,避免事件重复发送。
flowPortalTodoService.sendTask(processTaskId, addedActors);
} }
@Override @Override
@@ -290,7 +302,14 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void removeTaskActor(Long processTaskId, List<String> actors) { public void removeTaskActor(Long processTaskId, List<String> actors) {
if (CollectionUtil.isEmpty(actors)) return;
// 先保留被移除人员的门户账号,参与人记录清理后无法再据此定位待办。
List<ProcessTaskActor> removedActors = dao().query(ProcessTaskActor.class,
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", processTaskId)
.and(ProcessTaskActor::getActorId, "in", actors));
flowPortalTodoService.removeTask(processTaskId, removedActors);
dao().clear(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "=", processTaskId).and(ProcessTaskActor::getActorId, "in", actors)); dao().clear(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "=", processTaskId).and(ProcessTaskActor::getActorId, "in", actors));
} }
@@ -481,6 +500,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
task.setVariable(JSONUtil.toJsonStr(variable)); task.setVariable(JSONUtil.toJsonStr(variable));
updateIgnoreNull(task); updateIgnoreNull(task);
// 转办后原参与人不再办理,先清理原任务门户待办;新任务在添加参与人时发送。
flowPortalTodoService.removeTask(taskId, null);
// 创建转办任务 // 创建转办任务
ProcessTask newTask = BeanUtil.copyProperties(task, ProcessTask.class, "id"); ProcessTask newTask = BeanUtil.copyProperties(task, ProcessTask.class, "id");
newTask.setTaskParentId(taskId); newTask.setTaskParentId(taskId);