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 @@ - - - + + + +