diff --git a/src/main/java/com/budwk/app/base/wxwork/WxWorkMessageService.java b/src/main/java/com/budwk/app/base/wxwork/WxWorkMessageService.java
new file mode 100644
index 0000000..928dadd
--- /dev/null
+++ b/src/main/java/com/budwk/app/base/wxwork/WxWorkMessageService.java
@@ -0,0 +1,29 @@
+package com.budwk.app.base.wxwork;
+
+import org.nutz.lang.util.NutMap;
+import java.util.List;
+
+/** 企业微信自建应用消息服务,接收人的 loginname 必须对应学校企业微信 userid。 */
+public interface WxWorkMessageService {
+ /**
+ * 单人发送;link 为空发送文本,否则发送文本卡片,相对链接使用配置域名补全。
+ * @param loginname 接收人的企业微信 userid,当前学校直接使用系统 loginname
+ * @param title 消息标题,为空使用配置的默认标题
+ * @param content 消息正文,可含富文本标签,发送前转为纯文本
+ * @param link 业务详情链接,可为空、站内相对路径或完整 HTTP(S) 地址
+ * @return 与 massSend 相同的结果结构;skipped 表示关闭开关而未发送
+ */
+ NutMap send(String loginname, String title, String content, String link);
+
+ /**
+ * 多人发送相同消息,去重并分批调用企业微信接口。
+ * @param loginNames 接收人 loginname 列表,每项须为企业微信 userid,不接受 @all
+ * @param title 消息标题,为空使用默认标题
+ * @param content 消息正文,清理标签后不能为空
+ * @param link 详情链接;空值为文本消息,非空为文本卡片
+ * @return NutMap:success 为全部批次被接口接受且无无效接收人;skipped 为未发送;
+ * batches 为批次结果列表,每项包含 errcode、errmsg、msgid、invaliduser 等接口字段。
+ * 接口接受不代表用户已读;网络或配置错误抛出业务异常,不自动重发已接受批次。
+ */
+ NutMap massSend(List loginNames, String title, String content, String link);
+}
diff --git a/src/main/java/com/budwk/app/base/wxwork/impl/WxWorkMessageServiceImpl.java b/src/main/java/com/budwk/app/base/wxwork/impl/WxWorkMessageServiceImpl.java
new file mode 100644
index 0000000..d299ad7
--- /dev/null
+++ b/src/main/java/com/budwk/app/base/wxwork/impl/WxWorkMessageServiceImpl.java
@@ -0,0 +1,189 @@
+package com.budwk.app.base.wxwork.impl;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.http.HtmlUtil;
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.budwk.app.base.exception.BaseException;
+import com.budwk.app.base.constant.RedisConstant;
+import com.budwk.app.base.wxwork.WxWorkMessageService;
+import com.budwk.app.web.commons.base.Globals;
+import lombok.extern.slf4j.Slf4j;
+import org.nutz.aop.interceptor.ioc.TransAop;
+import org.nutz.integration.jedis.RedisService;
+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 org.nutz.lang.util.NutMap;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/** 企业微信官方消息接口;参考 ypi 的单发、群发及 token 缓存方式,配置与业务调用分离。 */
+@Slf4j
+@IocBean
+public class WxWorkMessageServiceImpl implements WxWorkMessageService {
+ private static final String PREFIX = "wxwork.message.";
+ private static final String API = "https://qyapi.weixin.qq.com/cgi-bin/";
+ @Inject
+ private PropertiesProxy conf;
+ @Inject
+ private RedisService redisService;
+
+ /** 参数和返回结构见接口;发送是外部操作,数据库事务无法撤回已发送的消息。 */
+ @Override
+ @Aop(TransAop.READ_COMMITTED)
+ public NutMap send(String loginname, String title, String content, String link) {
+ return massSend(loginname == null ? null : List.of(loginname), title, content, link);
+ }
+
+ /** 先判断独立消息开关,关闭时不读取凭据、不取 token,也不调用外部接口。 */
+ @Override
+ @Aop(TransAop.READ_COMMITTED)
+ public NutMap massSend(List loginNames, String title, String content, String link) {
+ Object switchValue = Globals.MyConfig.get("AppWxWorkMessage");
+ boolean enabled = Boolean.parseBoolean(switchValue == null
+ ? conf.get(PREFIX + "enabled", "false") : switchValue.toString());
+ if (!enabled) {
+ return NutMap.NEW().addv("success", false).addv("skipped", true).addv("batches", List.of());
+ }
+ if (loginNames == null || loginNames.isEmpty()
+ || loginNames.stream().anyMatch(name -> StrUtil.isBlank(name) || name.contains("|") || name.trim().startsWith("@"))) {
+ throw new BaseException("企业微信接收人须为非空 loginname,不允许分隔符或群发标记");
+ }
+ List receivers = loginNames.stream().map(String::trim).distinct().toList();
+ String body = plainText(content);
+ String heading = plainText(StrUtil.blankToDefault(title, conf.get(PREFIX + "default-title", "智慧工会")));
+ if (StrUtil.isBlank(body)) throw new BaseException("企业微信消息正文不能为空");
+ String url = resolveLink(link);
+ long agentId;
+ try {
+ agentId = Long.parseLong(required("wxwork.agentId"));
+ if (agentId <= 0) throw new NumberFormatException();
+ } catch (NumberFormatException e) {
+ throw new BaseException("企业微信 agentId 必须为正整数");
+ }
+ JSONObject payload = JSONUtil.createObj().set("agentid", agentId);
+ if (url == null) {
+ String text = heading + "\n" + body;
+ requireLength(text, 2048, "文本消息");
+ payload.set("msgtype", "text").set("text", JSONUtil.createObj().set("content", text));
+ } else {
+ requireLength(heading, 128, "卡片标题");
+ String description = HtmlUtil.escape(body).replace("\n", "
");
+ requireLength(description, 512, "卡片正文");
+ requireLength(url, 2048, "卡片链接");
+ payload.set("msgtype", "textcard").set("textcard", JSONUtil.createObj()
+ .set("title", heading).set("description", description).set("url", url).set("btntxt", "查看详情"));
+ }
+ List batches = new ArrayList<>();
+ boolean success = true;
+ // 企业微信 touser 单次最多 1000 人;按去重后的接收人分批,禁止自动重复整个批次列表。
+ for (int offset = 0; offset < receivers.size(); offset += 1000) {
+ payload.set("touser", String.join("|", receivers.subList(offset, Math.min(offset + 1000, receivers.size()))));
+ String token = getAccessToken(false);
+ JSONObject result = sendPayload(payload, token);
+ int code = result.getInt("errcode", -1);
+ // 仅在明确的 token 无效/过期返回时刷新一次;超时的发送结果不确定,不能盲目重发。
+ if (code == 40014 || code == 42001 || code == 40001) {
+ result = sendPayload(payload, getAccessToken(true));
+ }
+ batches.add(result);
+ boolean accepted = result.getInt("errcode", -1) == 0
+ && StrUtil.isBlank(result.getStr("invaliduser"))
+ && StrUtil.isBlank(result.getStr("unlicenseduser"));
+ success &= accepted;
+ log.info("企业微信消息批次完成:offset={},success={},errcode={},msgid={}",
+ offset, accepted, result.getInt("errcode"), result.getStr("msgid"));
+ }
+ return NutMap.NEW().addv("success", success).addv("skipped", false).addv("batches", batches);
+ }
+
+ /** 标题和富文本正文统一转换为可读纯文本,保留常用段落换行。 */
+ private String plainText(String value) {
+ return HtmlUtil.unescape(HtmlUtil.cleanHtmlTag(StrUtil.blankToDefault(value, "")
+ .replaceAll("(?i)
|
|", "\n"))).trim();
+ }
+
+ /** 使用 UTF-8 字节数校验接口上限,避免中文按字符计数造成发送失败。 */
+ private void requireLength(String value, int limit, String label) {
+ if (value.getBytes(StandardCharsets.UTF_8).length > limit) {
+ throw new BaseException(label + "超过 " + limit + " 字节,请精简内容");
+ }
+ }
+
+ /** 相对路径按配置的系统域名补全;限制 HTTP(S),避免生成不可打开的卡片链接。 */
+ private String resolveLink(String link) {
+ if (StrUtil.isBlank(link)) return null;
+ try {
+ URI uri = URI.create(link.trim());
+ if (!uri.isAbsolute()) {
+ if (uri.getRawAuthority() != null) throw new IllegalArgumentException();
+ String base = required(PREFIX + "link-base-url").replaceAll("/+$", "") + "/";
+ uri = URI.create(base).resolve(uri);
+ }
+ if (!("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))
+ || uri.getHost() == null || uri.getUserInfo() != null) throw new IllegalArgumentException();
+ return uri.toASCIIString();
+ } catch (IllegalArgumentException e) {
+ throw new BaseException("企业微信详情链接或系统域名配置无效");
+ }
+ }
+
+ /** 按企业及应用隔离缓存,与登录 token 缓存分开;提前过期以避开有效期临界点。 */
+ private synchronized String getAccessToken(boolean refresh) {
+ String corpId = required("wxwork.corpId");
+ String agentId = required("wxwork.agentId");
+ String secret = required("wxwork.corpSecret");
+ String key = RedisConstant.REDIS_KEY_WX_WORK_TOKEN + "message:" + corpId + ":" + agentId;
+ String cached = refresh ? null : redisService.get(key);
+ if (StrUtil.isNotBlank(cached)) return cached;
+ JSONObject response = execute(HttpRequest.get(API + "gettoken?corpid=" + encode(corpId)
+ + "&corpsecret=" + encode(secret)));
+ String token = response.getStr("access_token");
+ if (response.getInt("errcode", -1) != 0 || StrUtil.isBlank(token)) {
+ throw new BaseException("企业微信获取 token 失败,errcode=" + response.getInt("errcode", -1));
+ }
+ int expires = response.getInt("expires_in", 7200);
+ redisService.setex(key, Math.max(1, expires - 120), token);
+ return token;
+ }
+
+ /** 提交已校验的消息 JSON;返回企业微信原始业务字段供调用方判断部分失败。 */
+ private JSONObject sendPayload(JSONObject payload, String token) {
+ return execute(HttpRequest.post(API + "message/send?access_token=" + encode(token))
+ .header("Content-Type", "application/json; charset=UTF-8").body(payload.toString()));
+ }
+
+ /** 关闭响应资源并校验 HTTP/JSON;异常不输出带 Secret 或 token 的请求 URL。 */
+ protected JSONObject execute(HttpRequest request) {
+ request.setConnectionTimeout(conf.getInt(PREFIX + "connect-timeout", 5000));
+ request.setReadTimeout(conf.getInt(PREFIX + "read-timeout", 10000));
+ try (HttpResponse response = request.execute()) {
+ if (!response.isOk()) throw new BaseException("企业微信接口 HTTP 状态异常:" + response.getStatus());
+ if (!JSONUtil.isTypeJSONObject(response.body())) throw new BaseException("企业微信接口返回非 JSON 对象");
+ return JSONUtil.parseObj(response.body());
+ } catch (BaseException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new BaseException("企业微信请求失败,请检查网络及接口配置;异常类型:" + e.getClass().getSimpleName());
+ }
+ }
+
+ /** 必填参数不允许为空或使用原有占位符 1,防止错误配置请求外部接口。 */
+ private String required(String key) {
+ String value = conf.get(key, "").trim();
+ if (StrUtil.isBlank(value) || "1".equals(value)) throw new BaseException("请配置有效的 " + key);
+ return value;
+ }
+
+ private String encode(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+}
diff --git a/src/main/java/com/budwk/app/flow/listenter/ProcessTaskEndEventListener.java b/src/main/java/com/budwk/app/flow/listenter/ProcessTaskEndEventListener.java
index 553c49f..214a23f 100644
--- a/src/main/java/com/budwk/app/flow/listenter/ProcessTaskEndEventListener.java
+++ b/src/main/java/com/budwk/app/flow/listenter/ProcessTaskEndEventListener.java
@@ -4,6 +4,7 @@ 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.service.SchoolOaTodoService;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -17,11 +18,16 @@ public class ProcessTaskEndEventListener implements ProcessEventListener {
@Inject
private Dao dao;
+ @Inject
+ private SchoolOaTodoService schoolOaTodoService;
+
@Override
public void onEvent(ProcessEvent event) {
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_END) {
Long sourceId = event.getSourceId();
ProcessTask task = dao.fetch(ProcessTask.class, sourceId);
+ // service 区分正常完成与废弃,只有正常完成才发送通达 status=2。
+ schoolOaTodoService.completeTodo(task);
System.out.println("流程任务完成事件:" + task.getDisplayName());
}
}
diff --git a/src/main/java/com/budwk/app/flow/listenter/ProcessTaskRevokeEventListener.java b/src/main/java/com/budwk/app/flow/listenter/ProcessTaskRevokeEventListener.java
index 2e47045..71eef7c 100644
--- a/src/main/java/com/budwk/app/flow/listenter/ProcessTaskRevokeEventListener.java
+++ b/src/main/java/com/budwk/app/flow/listenter/ProcessTaskRevokeEventListener.java
@@ -9,6 +9,7 @@ import com.budwk.app.flow.engine.event.ProcessEventListener;
import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
+import com.budwk.app.flow.service.SchoolOaTodoService;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -22,11 +23,16 @@ public class ProcessTaskRevokeEventListener implements ProcessEventListener {
@Inject
private Dao dao;
+ @Inject
+ private SchoolOaTodoService schoolOaTodoService;
+
@Override
public void onEvent(ProcessEvent event) {
if(event.getEventType()== ProcessEventTypeEnum.PROCESS_TASK_REVOKE){
Long taskId = event.getSourceId();
ProcessTask task = dao.fetch(ProcessTask.class, taskId);
+ // 公开通达协议未定义取消,集中记录未同步事项,不把撤回冒充正常已办。
+ schoolOaTodoService.cancelTodo(task);
String taskDisplayName = task.getDisplayName();
diff --git a/src/main/java/com/budwk/app/flow/listenter/ProcessTaskStartEventListener.java b/src/main/java/com/budwk/app/flow/listenter/ProcessTaskStartEventListener.java
index d030783..5519fde 100644
--- a/src/main/java/com/budwk/app/flow/listenter/ProcessTaskStartEventListener.java
+++ b/src/main/java/com/budwk/app/flow/listenter/ProcessTaskStartEventListener.java
@@ -9,6 +9,7 @@ import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService;
+import com.budwk.app.zhgh.staffbenefit.difficulthelp.service.DifficultHelpCommonService;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
@@ -30,13 +31,22 @@ public class ProcessTaskStartEventListener implements ProcessEventListener {
private Dao dao;
@Inject
private GlobalMessageSendService globalMessageSendService;
+ @Inject
+ private DifficultHelpCommonService difficultHelpCommonService;
@Override
public void onEvent(ProcessEvent event) {
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_START) {
Long sourceId = event.getSourceId();
ProcessTask task = dao.fetch(ProcessTask.class, sourceId);
+ if (task == null) return;
sendMessage(task);
+ // 节点及接收人判断集中在业务 service;通知失败不向流程提交接口抛出异常。
+ try {
+ difficultHelpCommonService.notifyOfficeApproval(task);
+ } catch (Exception e) {
+ log.error("困难帮扶企业微信待办通知异常,taskId={}", sourceId, e);
+ }
}
}
diff --git a/src/main/java/com/budwk/app/flow/service/SchoolOaTodoService.java b/src/main/java/com/budwk/app/flow/service/SchoolOaTodoService.java
new file mode 100644
index 0000000..5f0caff
--- /dev/null
+++ b/src/main/java/com/budwk/app/flow/service/SchoolOaTodoService.java
@@ -0,0 +1,27 @@
+package com.budwk.app.flow.service;
+
+import com.budwk.app.flow.entity.ProcessTask;
+
+/** 通达 OA 待办同步入口,WF 调用方无需处理鉴权、人员映射或接口参数。 */
+public interface SchoolOaTodoService {
+ /**
+ * 推送已创建且已分配参与人的 WF 任务;首次自动提交的 startTask 不发送。
+ * @param task 包含任务 ID、实例 ID、状态和表单地址的完整任务
+ * @return 无返回值;开关关闭时跳过,开启后请求失败抛出业务异常
+ */
+ void createTodo(ProcessTask task);
+
+ /**
+ * 将已完成任务对应的接收人待办同步为 status=2;非完成状态不冒充已办。
+ * @param task 数据库中的完整任务,使用原发起人、接收人、标题和地址组装请求
+ * @return 无返回值;errcode=0 表示接口接受请求,原待办匹配规则仍需学校联调确认
+ */
+ void completeTodo(ProcessTask task);
+
+ /**
+ * 记录需取消的 OA 待办。通达公开文档未提供取消接口,本方法不发送虚构的删除请求。
+ * @param task 撤回、废弃或转办的原任务,用于记录任务 ID 和状态
+ * @return 无返回值;仅在开关开启时输出待人工处理提示,不代表 OA 已取消
+ */
+ void cancelTodo(ProcessTask task);
+}
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 7eb7380..376d9c2 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
@@ -29,6 +29,7 @@ import com.budwk.app.flow.enums.*;
import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.service.ProcessInstanceService;
import com.budwk.app.flow.service.ProcessTaskService;
+import com.budwk.app.flow.service.SchoolOaTodoService;
import com.budwk.app.flow.vo.ProcessTaskVO;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import org.nutz.aop.interceptor.ioc.TransAop;
@@ -56,6 +57,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme
@Inject
private ProcessDefineService processDefineService;
+ @Inject
+ private SchoolOaTodoService schoolOaTodoService;
+
@Override
public boolean save(ProcessTask param) {
param.setId(null);
@@ -213,6 +217,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme
System.out.println("创建任务:" + processTask.getTaskName() + "," + processTask.getDisplayName());
processTaskList.add(processTask);
addTaskActor(processTask.getId(), getTaskActors(taskModel, execution));
+ // 参与人落库后统一推送通达待办,开始监听器不再重复推送。
+ schoolOaTodoService.createTodo(processTask);
return processTaskList;
}
@@ -284,6 +290,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme
clone.setUpdatedBy(null);
dao().insert(clone);
addTaskActor(clone.getId(), CollectionUtil.newArrayList(actor));
+ // 并行加签绕过普通节点创建入口,需要为新增的个人任务同步待办。
+ schoolOaTodoService.createTodo(clone);
});
}
@@ -318,6 +326,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme
}
@Override
+ @Aop(TransAop.READ_COMMITTED)
public ProcessTask rejectTask(ProcessModel model, ProcessTask currentTask) {
Long taskParentId = currentTask.getTaskParentId();
if (ObjectUtil.isEmpty(taskParentId) || taskParentId.equals(0L)) {
@@ -351,6 +360,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme
}
saveProcessTask(task);
addTaskActor(task.getId(), CollectionUtil.newArrayList(task.getOperator()));
+ // 驳回创建了新的任务 ID,按新任务推送,避免复用历史待办。
+ schoolOaTodoService.createTodo(task);
return task;
}
@@ -424,6 +435,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme
System.out.println("创建会签任务:" + processTask.getTaskName() + "," + processTask.getDisplayName());
processTaskList.add(processTask);
addTaskActor(processTask.getId(), CollectionUtil.newArrayList(taskActor));
+ // 串行和并行会签均按实际生成的个人任务发送待办。
+ schoolOaTodoService.createTodo(processTask);
});
ProcessInstanceService processInstanceService = ServiceContext.find(ProcessInstanceService.class);
// 更新会签变量到流程实例参数中
@@ -496,9 +509,12 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme
// 创建转办任务参与者
addTaskActor(newTask.getId(), List.of(transferUserId));
+ // 通达取消协议待学校确认;先明确记录原待办未清理,再向新办理人推送。
+ schoolOaTodoService.cancelTodo(task);
+ schoolOaTodoService.createTodo(newTask);
// 发布流程任务开始事件
- List processTaskList = List.of(task);
+ List processTaskList = List.of(newTask);
processTaskList.forEach(processTask -> {
ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_START).sourceId(processTask.getId()).build());
});
diff --git a/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java b/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java
new file mode 100644
index 0000000..7baa136
--- /dev/null
+++ b/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java
@@ -0,0 +1,256 @@
+package com.budwk.app.flow.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.budwk.app.base.exception.BaseException;
+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.flow.service.SchoolOaTodoService;
+import com.budwk.app.sys.models.Sys_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.impl.PropertiesProxy;
+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.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 通达 V13 流程中心接口适配。所有配置、token 和 HTTP 请求集中在本服务。
+ * 接口依据:https://open.tongda2000.com/oa/v13/interface.html
+ * 同步请求失败向调用方抛出异常;本地事务不能撤销已经成功的远端请求。
+ */
+@Slf4j
+@IocBean
+public class SchoolOaTodoServiceImpl implements SchoolOaTodoService {
+ private static final String PREFIX = "school-oa.todo.";
+ @Inject
+ private Dao dao;
+ @Inject
+ private PropertiesProxy conf;
+
+ // token 由同步方法保护;实例内缓存有效期,避免每位接收人重复申请凭据。
+ private String accessToken;
+ private long tokenExpiresAt;
+ private String tokenConfig;
+
+ @Override
+ @Aop(TransAop.READ_COMMITTED)
+ public void createTodo(ProcessTask task) {
+ if (!isEnabled()) return;
+ requireTask(task);
+ if (isInitialTask(task) || !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) return;
+ sendTask(task, 1);
+ }
+
+ @Override
+ @Aop(TransAop.READ_COMMITTED)
+ public void completeTodo(ProcessTask task) {
+ if (!isEnabled()) return;
+ requireTask(task);
+ if (isInitialTask(task)) return;
+ // 废弃任务也会发布结束事件;尤其提案附议仍可能可办理,不能直接改为 OA 已办。
+ if (!ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) {
+ cancelTodo(task);
+ return;
+ }
+ sendTask(task, 2);
+ }
+
+ @Override
+ public void cancelTodo(ProcessTask task) {
+ if (!isEnabled()) return;
+ requireTask(task);
+ if (isInitialTask(task)) return;
+ log.warn("通达OA取消规则尚未提供,本次未发送取消请求,请核对OA原待办:taskId={},instanceId={},state={}",
+ task.getId(), task.getProcessInstanceId(), task.getTaskState());
+ }
+
+ /** 系统参数优先于文件配置,只有明确为 true 才启用;未配置不访问数据库或 OA。 */
+ private boolean isEnabled() {
+ Object enabled = Globals.MyConfig.get("AppSchoolOa");
+ return Boolean.parseBoolean(enabled == null ? conf.get(PREFIX + "enabled", "false") : enabled.toString());
+ }
+
+ private void requireTask(ProcessTask task) {
+ if (task == null || task.getId() == null || task.getProcessInstanceId() == null) {
+ throw new BaseException("通达OA同步失败:任务或流程实例ID为空");
+ }
+ }
+
+ private boolean isInitialTask(ProcessTask task) {
+ return Long.valueOf(0L).equals(task.getTaskParentId()) && "startTask".equals(task.getTaskName());
+ }
+
+ /**
+ * task 为完整 WF 任务;status 只允许本服务传入 1(待办)或 2(已办)。
+ * sender/recipient 使用学校绑定的第三方用户标识;content 为 JSON 字符串,url 为 UTF-8 Base64。
+ * 每个接收人单独请求,响应 JSON 中必须有 errcode=0;不采用参考项目的 uniqueId/state 协议。
+ */
+ private void sendTask(ProcessTask task, int status) {
+ ProcessInstance instance = dao.fetch(ProcessInstance.class, task.getProcessInstanceId());
+ if (instance == null) throw new BaseException("通达OA同步失败:流程实例不存在");
+ Sys_user sender = dao.fetch(Sys_user.class, instance.getOperator());
+ if (sender == null) throw new BaseException("通达OA同步失败:流程发起人不存在");
+ List actors = dao.query(ProcessTaskActor.class,
+ Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId()));
+ if (actors.isEmpty()) throw new BaseException("通达OA同步失败:任务参与人为空");
+
+ String senderId = userIdentifier(sender.getId(), sender.getLoginname());
+ String title = StrUtil.blankToDefault(JSONUtil.parseObj(StrUtil.blankToDefault(instance.getVariable(), "{}"))
+ .getStr(FlowConst.PROCESS_INSTANCE_NAME), "流程申请") + " - " + task.getDisplayName();
+ String targetUrl = buildTaskUrl(task, instance);
+ String systemParam = conf.get(PREFIX + "system-id-param", "clientid");
+ if (!"clientid".equals(systemParam) && !"exsId".equals(systemParam)) {
+ throw new BaseException("通达OA配置 system-id-param 仅支持 clientid 或 exsId");
+ }
+ Map params = new LinkedHashMap<>();
+ params.put(systemParam, requireConfig("system-id"));
+ params.put("status", status);
+ params.put("sender", senderId);
+ params.put("content", JSONUtil.quote(title));
+ params.put("url", Base64.getEncoder().encodeToString(targetUrl.getBytes(StandardCharsets.UTF_8)));
+ // 先校验全部人员,避免处理到中途才发现缺少账号;不假设本地用户 ID 就是 OA 用户 ID。
+ List recipients = actors.stream()
+ .filter(actor -> !FlowConst.ADMIN_ID.equals(actor.getActorId())
+ && !"superadmin".equalsIgnoreCase(actor.getActorAccount()))
+ .map(actor -> userIdentifier(actor.getActorId(), actor.getActorAccount())).distinct().toList();
+ for (String recipient : recipients) {
+ params.put("recipient", recipient);
+ String endpoint = baseUrl() + "/restapi/1_0/appcenter/affairprocessing?" + encodeParams(params);
+ JSONObject result = execute(HttpRequest.get(endpoint).header("Authorization", "Bearer " + getAccessToken()), "待办同步");
+ Integer errorCode = result.getInt("errcode");
+ if (errorCode == null || errorCode != 0) {
+ // 远端消息可能回显密钥或请求地址,不把原始响应直接输出到日志和页面。
+ throw new BaseException("通达OA待办同步失败:errcode={},taskId={}", errorCode, task.getId());
+ }
+ log.info("通达OA接受待办同步请求,taskId={},status={}", task.getId(), status);
+ }
+ }
+
+ /** 用户映射按学校绑定选择 loginname 或 id,发送人与接收人使用同一规则。 */
+ private String userIdentifier(String id, String account) {
+ String mode = conf.get(PREFIX + "user-id-mode", "loginname");
+ if (!"loginname".equals(mode) && !"id".equals(mode)) {
+ throw new BaseException("通达OA配置 user-id-mode 仅支持 loginname 或 id");
+ }
+ String value = "id".equals(mode) ? id : account;
+ if (StrUtil.isBlank(value) || value.contains(",")) {
+ throw new BaseException("通达OA同步失败:绑定用户标识为空或包含逗号");
+ }
+ return value;
+ }
+
+ /** 通达仅有一个 url 字段,沿用当前 PC 审核页并携带任务、实例及业务参数。 */
+ private String buildTaskUrl(ProcessTask task, ProcessInstance instance) {
+ String form = task.getFormKey();
+ if (StrUtil.isBlank(form)) throw new BaseException("通达OA同步失败:任务PC表单地址为空");
+ String url = form.startsWith("http://") || form.startsWith("https://") ? form
+ : StrUtil.removeSuffix(Globals.AppDomain, "/") + "/" + StrUtil.removePrefix(form, "/");
+ // 原表单带锚点时,查询参数必须放在 # 前,否则服务端收不到 taskId。
+ int fragmentIndex = url.indexOf('#');
+ String fragment = fragmentIndex < 0 ? "" : url.substring(fragmentIndex);
+ if (fragmentIndex >= 0) url = url.substring(0, fragmentIndex);
+ Map query = new LinkedHashMap<>();
+ query.put("taskId", task.getId());
+ query.put("instanceId", instance.getId());
+ query.put("bizId", StrUtil.nullToEmpty(instance.getBusinessNo()));
+ query.put("taskKey", StrUtil.nullToEmpty(task.getTaskName()));
+ return url + (url.contains("?") ? "&" : "?") + encodeParams(query) + fragment;
+ }
+
+ /**
+ * POST /oauth2/token:client_secret 必须是学校提供的 AES 加密结果,不是明文密钥。
+ * 返回 access_token 和 expires_in(秒);缓存提前过期,配置变化立即重新申请。
+ */
+ private synchronized String getAccessToken() {
+ String base = baseUrl();
+ String clientId = requireConfig("client-id");
+ String secret = requireConfig("client-secret-encrypted");
+ String username = conf.get(PREFIX + "token-username", "username");
+ String password = conf.get(PREFIX + "token-password", "password");
+ String currentConfig = JSONUtil.toJsonStr(List.of(base, clientId, secret, username, password));
+ long now = System.currentTimeMillis();
+ if (currentConfig.equals(tokenConfig) && StrUtil.isNotBlank(accessToken) && now < tokenExpiresAt) return accessToken;
+ Map form = new LinkedHashMap<>();
+ form.put("grant_type", "password");
+ form.put("client_id", clientId);
+ form.put("client_secret", secret);
+ form.put("username", username);
+ form.put("password", password);
+ JSONObject result = execute(HttpRequest.post(base + "/oauth2/token")
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .body(encodeParams(form)), "获取凭据");
+ String token = result.getStr("access_token");
+ Long expires = result.getLong("expires_in");
+ if (StrUtil.isBlank(token) || expires == null || expires <= 0 || expires > Integer.MAX_VALUE) {
+ throw new BaseException("通达OA获取凭据失败:缺少有效的 access_token 或 expires_in");
+ }
+ accessToken = token;
+ tokenConfig = currentConfig;
+ tokenExpiresAt = now + (expires - Math.min(60, expires / 10)) * 1000L;
+ return token;
+ }
+
+ /** HTTP 成功不代表业务成功;本方法只验证状态码和 JSON,业务码由调用处校验。 */
+ private JSONObject execute(HttpRequest request, String action) {
+ try (HttpResponse response = request.timeout(conf.getInt(PREFIX + "timeout-ms", 10000)).execute()) {
+ if (!response.isOk()) {
+ if (response.getStatus() == 401) invalidateToken();
+ throw new BaseException("通达OA{}失败:HTTP {}", action, response.getStatus());
+ }
+ if (!JSONUtil.isTypeJSONObject(response.body())) throw new BaseException("通达OA{}失败:响应不是JSON对象", action);
+ return JSONUtil.parseObj(response.body());
+ } catch (BaseException e) {
+ throw e;
+ } catch (Exception e) {
+ // 不自动重放写请求,超时可能发生在远端已成功之后;避免重复待办。
+ throw new BaseException("通达OA{}异常({}),请核对远端结果后再重试", action, e.getClass().getSimpleName());
+ }
+ }
+
+ private synchronized void invalidateToken() {
+ accessToken = null;
+ tokenExpiresAt = 0;
+ }
+
+ private String baseUrl() {
+ String value = requireConfig("base-url");
+ URI uri = URI.create(value);
+ if ((!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme()))
+ || uri.getHost() == null || uri.getQuery() != null || uri.getFragment() != null || uri.getUserInfo() != null) {
+ throw new BaseException("通达OA base-url 必须为不含账号、查询参数和锚点的HTTP(S)地址");
+ }
+ return StrUtil.removeSuffix(value, "/");
+ }
+
+ private String requireConfig(String key) {
+ String value = conf.get(PREFIX + key);
+ if (StrUtil.isBlank(value)) throw new BaseException("通达OA配置 {} 未填写", PREFIX + key);
+ return value.trim();
+ }
+
+ /** 每个参数单独编码,保留标题中的中文、引号及 Base64 中的 +、/、=。 */
+ private String encodeParams(Map params) {
+ return params.entrySet().stream().map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
+ + "=" + URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8))
+ .collect(Collectors.joining("&"));
+ }
+}
diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysMsgServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysMsgServiceImpl.java
index 0b0ac40..d1f574b 100644
--- a/src/main/java/com/budwk/app/sys/services/impl/SysMsgServiceImpl.java
+++ b/src/main/java/com/budwk/app/sys/services/impl/SysMsgServiceImpl.java
@@ -6,6 +6,7 @@ import cn.hutool.http.HtmlUtil;
import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.sms.SmsService;
+import com.budwk.app.base.wxwork.WxWorkMessageService;
import com.budwk.app.base.sms.model.MsgPlatformWechatRequest;
import com.budwk.app.base.utils.ManyAddOrRenewUtil;
import com.budwk.app.sys.models.Sys_msg;
@@ -62,6 +63,8 @@ public class SysMsgServiceImpl extends BaseServiceImpl implements SysMs
private ManyAddOrRenewUtil manyAddOrRenewUtil;
@Inject
private SmsService smsService;
+ @Inject
+ private WxWorkMessageService wxWorkMessageService;
private static NutMap typeMap = NutMap.NEW().addv("system", "系统消息").addv("user", "用户消息");
@@ -102,14 +105,29 @@ public class SysMsgServiceImpl extends BaseServiceImpl implements SysMs
}
});
- //发送学校平台消息
- ThreadUtil.execute(() -> {
- for (String loginName : loginNames) {
- smsService.sendSmsByAccount(loginName,sysMsg.getNote());
- smsService.sendWechatTextByAccountOrMobile(loginName,sysMsg.getNote());
- }
-
- });
+ // isExternal=false 为仅站内通知;外部通道分别处理异常,避免短信失败阻断企业微信。
+ if (isExternal) {
+ ThreadUtil.execute(() -> {
+ for (String loginName : loginNames) {
+ try {
+ smsService.sendSmsByAccount(loginName, sysMsg.getNote());
+ } catch (Exception e) {
+ log.error("消息短信发送失败,msgId=" + sysMsg.getId(), e);
+ }
+ }
+ });
+ ThreadUtil.execute(() -> {
+ try {
+ // 学校企业微信 userid 直接使用 loginname;批量发送标题、正文及业务链接。
+ NutMap result = wxWorkMessageService.massSend(loginNames, sysMsg.getTitle(), sysMsg.getNote(), sysMsg.getUrl());
+ if (!result.getBoolean("skipped", false) && !result.getBoolean("success", false)) {
+ log.warn("企业微信消息存在失败接收人,msgId=" + sysMsg.getId() + ",result=" + result);
+ }
+ } catch (Exception e) {
+ log.error("企业微信消息发送失败,msgId=" + sysMsg.getId(), e);
+ }
+ });
+ }
return sysMsg;
}
@@ -292,11 +310,13 @@ public class SysMsgServiceImpl extends BaseServiceImpl implements SysMs
}
@Override
+ @Aop(TransAop.READ_COMMITTED)
public void sendMsg(String loginname, String title, String body, String sender) {
sendMsg(List.of(loginname), title, body, SecurityUtil.getUserId());
}
@Override
+ @Aop(TransAop.READ_COMMITTED)
public void sendMsg(List loginNames, String title, String body, String sender) {
Sys_msg sysMsg = new Sys_msg();
sysMsg.setTitle(title);
@@ -309,6 +329,7 @@ public class SysMsgServiceImpl extends BaseServiceImpl implements SysMs
}
@Override
+ @Aop(TransAop.READ_COMMITTED)
public void sendMsgInSys(List loginNames, String title, String body, String sender) {
Sys_msg sysMsg = new Sys_msg();
sysMsg.setTitle(title);
diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/difficulthelp/service/DifficultHelpCommonService.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/difficulthelp/service/DifficultHelpCommonService.java
index 5deeb26..ea7a9de 100644
--- a/src/main/java/com/budwk/app/zhgh/staffbenefit/difficulthelp/service/DifficultHelpCommonService.java
+++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/difficulthelp/service/DifficultHelpCommonService.java
@@ -1,6 +1,7 @@
package com.budwk.app.zhgh.staffbenefit.difficulthelp.service;
import com.budwk.app.base.service.BaseService;
+import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.models.DifficultHelpInfo;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.DifficultHelpPageParam;
import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm;
@@ -21,6 +22,14 @@ public interface DifficultHelpCommonService extends BaseService implements DifficultHelpCommonService {
+ private static final Log log = Logs.get();
+ private static final String OFFICE_APPROVAL_TASK = "36ebafda-44c2-4bd3-bd7f-10839a575a0d";
public DifficultHelpCommonServiceImpl(Dao dao) {
super(dao);
@@ -53,6 +63,55 @@ public class DifficultHelpCommonServiceImpl extends BaseServiceImpl actors = dao().query(ProcessTaskActor.class,
+ Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId()));
+ List loginNames = actors.stream().map(ProcessTaskActor::getActorAccount)
+ .filter(StrUtil::isNotBlank).map(String::trim).distinct().toList();
+ // 不按角色扩大接收范围,未绑定工号时不以内部用户 ID 代替企业微信 userid。
+ if (loginNames.isEmpty()) {
+ log.warnf("困难帮扶企业微信通知跳过:审核人 loginname 为空,taskId=%s", task.getId());
+ return;
+ }
+ String content = "您有一条困难帮扶申请待审核,受助人:"
+ + StrUtil.blankToDefault(info.getUserName(), "未填写") + ",请进入校工会办公室审批页面办理。";
+ // 流程定义中仍保留旧的 unionLeaderApproval 链接,本节点实际办理入口为 branchUnionApproval。
+ NutMap result = wxWorkMessageService.massSend(loginNames, "困难帮扶待办提醒", content,
+ "/platform/difficultHelp/branchUnionApproval/h5");
+ if (!result.getBoolean("skipped", false) && !result.getBoolean("success", false)) {
+ log.warnf("困难帮扶企业微信通知发送失败或部分失败,taskId=%s,result=%s", task.getId(), result);
+ }
+ } catch (Exception e) {
+ // 外部消息不改变审批结果;日志保留任务 ID,便于定位接口、账号或配置问题。
+ log.error("困难帮扶企业微信通知异常,taskId=" + task.getId(), e);
+ }
+ }
@Override
public void validateApplyUser(DifficultHelpInfo difficultHelpInfo) {
diff --git a/src/main/resources/static/WW_verify_7pKr23u2tZHcvXud.txt b/src/main/resources/static/WW_verify_7pKr23u2tZHcvXud.txt
new file mode 100644
index 0000000..1c78351
--- /dev/null
+++ b/src/main/resources/static/WW_verify_7pKr23u2tZHcvXud.txt
@@ -0,0 +1 @@
+7pKr23u2tZHcvXud
\ No newline at end of file
diff --git a/src/main/resources/views/layouts/v4/baseLayout.html b/src/main/resources/views/layouts/v4/baseLayout.html
index 2350c7f..3e68c78 100644
--- a/src/main/resources/views/layouts/v4/baseLayout.html
+++ b/src/main/resources/views/layouts/v4/baseLayout.html
@@ -68,9 +68,10 @@
-
-
-
+
+
+
+