diff --git a/src/main/java/com/budwk/app/base/portal/PortalTodoLogService.java b/src/main/java/com/budwk/app/base/portal/PortalTodoLogService.java new file mode 100644 index 00000000..62ce7e67 --- /dev/null +++ b/src/main/java/com/budwk/app/base/portal/PortalTodoLogService.java @@ -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); +} diff --git a/src/main/java/com/budwk/app/base/portal/PortalTodoService.java b/src/main/java/com/budwk/app/base/portal/PortalTodoService.java new file mode 100644 index 00000000..08d1265b --- /dev/null +++ b/src/main/java/com/budwk/app/base/portal/PortalTodoService.java @@ -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); +} diff --git a/src/main/java/com/budwk/app/base/portal/README.md b/src/main/java/com/budwk/app/base/portal/README.md new file mode 100644 index 00000000..1e851ebc --- /dev/null +++ b/src/main/java/com/budwk/app/base/portal/README.md @@ -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`;本次保持该业务规则,仅为实际恢复的任务重发待办。 diff --git a/src/main/java/com/budwk/app/base/portal/impl/PortalTodoLogServiceImpl.java b/src/main/java/com/budwk/app/base/portal/impl/PortalTodoLogServiceImpl.java new file mode 100644 index 00000000..1dd7dd98 --- /dev/null +++ b/src/main/java/com/budwk/app/base/portal/impl/PortalTodoLogServiceImpl.java @@ -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); + } +} diff --git a/src/main/java/com/budwk/app/base/portal/impl/PortalTodoServiceImpl.java b/src/main/java/com/budwk/app/base/portal/impl/PortalTodoServiceImpl.java new file mode 100644 index 00000000..fa810096 --- /dev/null +++ b/src/main/java/com/budwk/app/base/portal/impl/PortalTodoServiceImpl.java @@ -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状态={},耗时={}ms,code={},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; + } +} diff --git a/src/main/java/com/budwk/app/base/portal/model/PortalTodoLog.java b/src/main/java/com/budwk/app/base/portal/model/PortalTodoLog.java new file mode 100644 index 00000000..1e453cf4 --- /dev/null +++ b/src/main/java/com/budwk/app/base/portal/model/PortalTodoLog.java @@ -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; +} diff --git a/src/main/java/com/budwk/app/base/portal/model/PortalTodoRequest.java b/src/main/java/com/budwk/app/base/portal/model/PortalTodoRequest.java new file mode 100644 index 00000000..eddb831a --- /dev/null +++ b/src/main/java/com/budwk/app/base/portal/model/PortalTodoRequest.java @@ -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 targets; +} diff --git a/src/main/java/com/budwk/app/base/portal/model/PortalTodoResponse.java b/src/main/java/com/budwk/app/base/portal/model/PortalTodoResponse.java new file mode 100644 index 00000000..5168a13e --- /dev/null +++ b/src/main/java/com/budwk/app/base/portal/model/PortalTodoResponse.java @@ -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; +} diff --git a/src/main/java/com/budwk/app/base/portal/model/PortalTodoSendRequest.java b/src/main/java/com/budwk/app/base/portal/model/PortalTodoSendRequest.java new file mode 100644 index 00000000..ed00b153 --- /dev/null +++ b/src/main/java/com/budwk/app/base/portal/model/PortalTodoSendRequest.java @@ -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; +} diff --git a/src/main/java/com/budwk/app/flow/listenter/ProcessPortalTodoEventListener.java b/src/main/java/com/budwk/app/flow/listenter/ProcessPortalTodoEventListener.java new file mode 100644 index 00000000..ce2bc7bb --- /dev/null +++ b/src/main/java/com/budwk/app/flow/listenter/ProcessPortalTodoEventListener.java @@ -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()); + } + } +} diff --git a/src/main/java/com/budwk/app/flow/service/FlowCommonService.java b/src/main/java/com/budwk/app/flow/service/FlowCommonService.java index b77824d8..20408e72 100644 --- a/src/main/java/com/budwk/app/flow/service/FlowCommonService.java +++ b/src/main/java/com/budwk/app/flow/service/FlowCommonService.java @@ -35,6 +35,9 @@ public class FlowCommonService { @Inject private Dao dao; + @Inject + private FlowPortalTodoService flowPortalTodoService; + /** * 执行任务 * @param args @@ -111,9 +114,12 @@ public class FlowCommonService { // 撤销任务 List taskList = dao.query(ProcessTask.class, Cnd.where(ProcessTask::getTaskParentId, "=", taskId)); + // 收集全部被撤回的任务,包含并行会签的非直接子任务,避免门户残留待办。 + java.util.Set revokedTaskIds = new java.util.LinkedHashSet<>(); for (ProcessTask task : taskList) { task.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode()); dao.update(task); + revokedTaskIds.add(task.getId()); } // 会签并行任务 撤销后续任务 if (selfTask.getPerformType().equals(ProcessTaskPerformTypeEnum.COUNTERSIGN.getCode())) { @@ -122,6 +128,7 @@ public class FlowCommonService { for (ProcessTask doingTask : doingTasks) { doingTask.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode()); 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()); } + // 直接子任务由撤回事件同步;额外并行任务在此补齐,不重复删除同一任务。 + taskList.forEach(task -> revokedTaskIds.remove(task.getId())); + revokedTaskIds.forEach(id -> flowPortalTodoService.removeTask(id, null)); + // 原任务重新进入办理状态,需要恢复它在门户中的待办。 + flowPortalTodoService.sendTask(selfTask.getId(), null); + return Result.success(); } diff --git a/src/main/java/com/budwk/app/flow/service/FlowPortalTodoService.java b/src/main/java/com/budwk/app/flow/service/FlowPortalTodoService.java new file mode 100644 index 00000000..7c4a62b4 --- /dev/null +++ b/src/main/java/com/budwk/app/flow/service/FlowPortalTodoService.java @@ -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 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 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 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 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 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 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 提供bizId(businessNo)的流程实例 + * @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); + } +} diff --git a/src/main/java/com/budwk/app/flow/service/impl/ProcessInstanceServiceImpl.java b/src/main/java/com/budwk/app/flow/service/impl/ProcessInstanceServiceImpl.java index 0ea552b7..d090ed38 100644 --- a/src/main/java/com/budwk/app/flow/service/impl/ProcessInstanceServiceImpl.java +++ b/src/main/java/com/budwk/app/flow/service/impl/ProcessInstanceServiceImpl.java @@ -28,6 +28,7 @@ import com.budwk.app.flow.enums.ProcessInstanceStateEnum; import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; import com.budwk.app.flow.enums.ProcessTaskStateEnum; 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.ProcessTaskService; import com.budwk.app.flow.vo.HighLightVO; @@ -57,6 +58,9 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl @Inject private ProcessDefineService processDefineService; + @Inject + private FlowPortalTodoService flowPortalTodoService; + @Override @Aop(TransAop.READ_COMMITTED) public boolean save(ProcessInstance param) { @@ -198,10 +202,16 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl processInstance.setState(ProcessInstanceStateEnum.INTERRUPT.getCode()); dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode())); + // 终止后任务不可办理,同步清理门户待办。 + flowPortalTodoService.removeInstance(processInstanceId); } @Override + @Aop(TransAop.READ_COMMITTED) public void resume(Long processInstanceId, String operator) { + List restoredTasks = dao().query(ProcessTask.class, + Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) + .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); Date now = new Date(); ProcessInstance processInstance = fetch(processInstanceId); // 1. 更新流程实例状态为进行中 @@ -212,9 +222,12 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode()); dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); + // 仅恢复本次由终止状态重新进入办理的任务,避免重复发送已有待办。 + restoredTasks.forEach(task -> flowPortalTodoService.sendTask(task.getId(), null)); } @Override + @Aop(TransAop.READ_COMMITTED) public void pending(Long processInstanceId, String operator) { Date now = new Date(); // 1. 将该流程实例产生的任务状态修改为挂起 @@ -227,10 +240,17 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl processInstance.setState(ProcessInstanceStateEnum.PENDING.getCode()); dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode())); + // 挂起期间不应继续展示可办理的门户待办。 + flowPortalTodoService.removeInstance(processInstanceId); } @Override + @Aop(TransAop.READ_COMMITTED) public void activate(Long processInstanceId, String operator) { + // 沿用当前 activate 的状态选择规则,只同步实际被恢复的任务。 + List restoredTasks = dao().query(ProcessTask.class, + Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) + .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); ProcessInstance processInstance = fetch(processInstanceId); // 1. 更新流程实例状态为进行中 processInstance.setState(ProcessInstanceStateEnum.DOING.getCode()); @@ -240,6 +260,7 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode()); dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); + restoredTasks.forEach(task -> flowPortalTodoService.sendTask(task.getId(), null)); } @Override @@ -323,6 +344,7 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl } @Override + @Aop(TransAop.READ_COMMITTED) public void withdraw(Long processInstanceId, String operator) { // Date now = new Date(); // 1. 将该流程实例状态修改为撤回 @@ -342,6 +364,8 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl processTask.setUpdatedBy(operator); } dao().update(taskList, "taskState|updateAt|updateBy"); + // 实例撤回没有任务撤回事件,在此同步删除已不可办理的门户待办。 + flowPortalTodoService.removeInstance(processInstanceId); } // int update = dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) @@ -555,6 +579,8 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl @Override @Aop(TransAop.READ_COMMITTED) public void deleteProcessInstanceById(Long processInstanceId) { + // 在实例、任务和参与人删除前清理门户,否则无法重建原待办定位参数。 + flowPortalTodoService.removeInstance(processInstanceId); List taskList = dao().query(ProcessTask.class, Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)); List taskIds = taskList.stream().map(ProcessTask::getId).toList(); // 任务参与人依赖流程任务,删除流程时先清理参与人,避免遗留无法回查所属流程的孤立记录。 diff --git a/src/main/java/com/budwk/app/flow/service/impl/ProcessTaskServiceImpl.java b/src/main/java/com/budwk/app/flow/service/impl/ProcessTaskServiceImpl.java index 7eb73805..8d2dc5e4 100644 --- a/src/main/java/com/budwk/app/flow/service/impl/ProcessTaskServiceImpl.java +++ b/src/main/java/com/budwk/app/flow/service/impl/ProcessTaskServiceImpl.java @@ -27,6 +27,7 @@ import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.entity.ProcessTaskActor; import com.budwk.app.flow.enums.*; 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.ProcessTaskService; import com.budwk.app.flow.vo.ProcessTaskVO; @@ -56,6 +57,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme @Inject private ProcessDefineService processDefineService; + @Inject + private FlowPortalTodoService flowPortalTodoService; + @Override public boolean save(ProcessTask param) { param.setId(null); @@ -125,6 +129,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme } @Override + @Aop(TransAop.READ_COMMITTED) public void finishProcessTask(Long processTaskId, String operator, Dict args) { ProcessTask his = fetch(processTaskId); ProcessTask processTask = new ProcessTask(); @@ -149,6 +154,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme } @Override + @Aop(TransAop.READ_COMMITTED) public void abandonProcessTask(Long processTaskId, String operator, Dict args) { ProcessTask his = fetch(processTaskId); ProcessTask processTask = new ProcessTask(); @@ -228,10 +234,13 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme if (CollectionUtil.isEmpty(actors)) return; List dbActors = getTaskActors(processTaskId); List 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.setParam("ids", newActors); List list = listMap(sql); + List addedActors = new ArrayList<>(); + for (NutMap actor : list) { ProcessTaskActor processTaskActor = new ProcessTaskActor(); processTaskActor.setProcessTaskId(processTaskId); @@ -243,7 +252,10 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme processTaskActor.setCreatedAt(System.currentTimeMillis()); System.out.println(StrUtil.format("给任务:{},添加参与者:{}", processTaskId, actor.toString())); insert(processTaskActor); + addedActors.add(processTaskActor); } + // 只为实际新增的账号发送,覆盖普通任务、串并行会签、转办和加签,避免事件重复发送。 + flowPortalTodoService.sendTask(processTaskId, addedActors); } @Override @@ -290,7 +302,14 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme } @Override + @Aop(TransAop.READ_COMMITTED) public void removeTaskActor(Long processTaskId, List actors) { + if (CollectionUtil.isEmpty(actors)) return; + // 先保留被移除人员的门户账号,参与人记录清理后无法再据此定位待办。 + List 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)); } @@ -481,6 +500,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme task.setVariable(JSONUtil.toJsonStr(variable)); updateIgnoreNull(task); + // 转办后原参与人不再办理,先清理原任务门户待办;新任务在添加参与人时发送。 + flowPortalTodoService.removeTask(taskId, null); + // 创建转办任务 ProcessTask newTask = BeanUtil.copyProperties(task, ProcessTask.class, "id"); newTask.setTaskParentId(taskId);