企业微信消息和oa待办
This commit is contained in:
@@ -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<String> loginNames, String title, String content, String link);
|
||||
}
|
||||
@@ -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<String> 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<String> 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", "<br>");
|
||||
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<JSONObject> 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)<br\\s*/?>|</p>|</div>", "\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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ProcessTask> 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<ProcessTask> 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<ProcessTask> 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<ProcessTask> 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<ProcessTask> 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<ProcessTask> 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<ProcessTask> impleme
|
||||
|
||||
// 创建转办任务参与者
|
||||
addTaskActor(newTask.getId(), List.of(transferUserId));
|
||||
// 通达取消协议待学校确认;先明确记录原待办未清理,再向新办理人推送。
|
||||
schoolOaTodoService.cancelTodo(task);
|
||||
schoolOaTodoService.createTodo(newTask);
|
||||
|
||||
// 发布流程任务开始事件
|
||||
List<ProcessTask> processTaskList = List.of(task);
|
||||
List<ProcessTask> processTaskList = List.of(newTask);
|
||||
processTaskList.forEach(processTask -> {
|
||||
ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_START).sourceId(processTask.getId()).build());
|
||||
});
|
||||
|
||||
@@ -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<ProcessTaskActor> 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<String, Object> 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<String> 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<String, Object> 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<String, Object> 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<String, Object> 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("&"));
|
||||
}
|
||||
}
|
||||
@@ -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<Sys_msg> 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<Sys_msg> 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<Sys_msg> 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<String> 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<Sys_msg> implements SysMs
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void sendMsgInSys(List<String> loginNames, String title, String body, String sender) {
|
||||
Sys_msg sysMsg = new Sys_msg();
|
||||
sysMsg.setTitle(title);
|
||||
|
||||
+9
@@ -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<DifficultHelpInf
|
||||
|
||||
void validateApplyUser(DifficultHelpInfo difficultHelpInfo);
|
||||
|
||||
/**
|
||||
* 困难帮扶进入校工会办公室主任审批时,通知本次任务的实际参与人。
|
||||
* @param task WF 已创建且参与人已落库的任务;仅 KNBF 办公室审批待办触发,
|
||||
* 接收人使用任务参与人的 actorAccount(系统 loginname)
|
||||
* @return 无返回值;开关关闭或非目标节点不发送,发送失败记录日志而不阻断流程
|
||||
*/
|
||||
void notifyOfficeApproval(ProcessTask task);
|
||||
|
||||
/**
|
||||
* 导入实际金额数据处理
|
||||
* @param file 文件
|
||||
|
||||
+59
@@ -6,6 +6,12 @@ import cn.hutool.json.JSONUtil;
|
||||
import com.budwk.app.base.service.impl.BaseServiceImpl;
|
||||
import com.budwk.app.base.constant.RoleConstant;
|
||||
import com.budwk.app.base.exception.BaseException;
|
||||
import com.budwk.app.base.wxwork.WxWorkMessageService;
|
||||
import com.budwk.app.flow.entity.ProcessDefine;
|
||||
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.web.commons.auth.utils.AuthUtil;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.base.utils.ManyAddOrRenewUtil;
|
||||
@@ -31,6 +37,8 @@ import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Lang;
|
||||
import org.nutz.lang.random.R;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
import org.nutz.log.Log;
|
||||
import org.nutz.log.Logs;
|
||||
import org.nutz.mvc.upload.TempFile;
|
||||
|
||||
import java.util.*;
|
||||
@@ -46,6 +54,8 @@ import java.util.stream.IntStream;
|
||||
*/
|
||||
@IocBean(args = {"refer:dao"})
|
||||
public class DifficultHelpCommonServiceImpl extends BaseServiceImpl<DifficultHelpInfo> 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<DifficultHel
|
||||
|
||||
@Inject
|
||||
private ManyAddOrRenewUtil manyAddOrRenewUtil;
|
||||
@Inject
|
||||
private WxWorkMessageService wxWorkMessageService;
|
||||
|
||||
/**
|
||||
* 只通知 KNBF 办公室待办的实际审核人;重新提交生成的新任务沿用同一入口。
|
||||
* task 为任务开始事件对应的 WF 任务,actorAccount 为学校企业微信 userid。
|
||||
* 无返回值;服务返回 skipped 表示开关关闭,success=false 表示接口失败或部分接收人无效。
|
||||
*/
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void notifyOfficeApproval(ProcessTask task) {
|
||||
if (task == null || task.getId() == null || task.getProcessInstanceId() == null
|
||||
|| !OFFICE_APPROVAL_TASK.equals(task.getTaskName())
|
||||
|| !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ProcessInstance instance = dao().fetch(ProcessInstance.class, task.getProcessInstanceId());
|
||||
if (instance == null || instance.getProcessDefineId() == null) return;
|
||||
ProcessDefine define = dao().fetch(ProcessDefine.class, instance.getProcessDefineId());
|
||||
// 使用实例所属定义核验流程 KEY,避免其他流程复用同一个节点 ID 时误发。
|
||||
if (define == null || !"KNBF".equals(define.getName())) return;
|
||||
DifficultHelpInfo info = StrUtil.isBlank(instance.getBusinessNo()) ? null : fetch(instance.getBusinessNo());
|
||||
if (info == null) {
|
||||
log.warnf("困难帮扶企业微信通知跳过:申请不存在,taskId=%s", task.getId());
|
||||
return;
|
||||
}
|
||||
List<ProcessTaskActor> actors = dao().query(ProcessTaskActor.class,
|
||||
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId()));
|
||||
List<String> 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) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
7pKr23u2tZHcvXud
|
||||
@@ -68,9 +68,10 @@
|
||||
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.css" />
|
||||
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-table.css" />
|
||||
|
||||
<script src="https://vxeui.com/umd/xe-utils@3.5.30/dist/xe-utils.umd.min.js"></script>
|
||||
<script src="https://vxeui.com/umd/vxe-pc-ui@3.1.25/lib/index.umd.min.js"></script>
|
||||
<script src="https://vxeui.com/umd/vxe-table@3.9.0/lib/index.umd.min.js"></script>
|
||||
<!-- 使用随应用打包的本地资源,按工具库、UI、表格的依赖顺序加载,避免外链失效影响页面。 -->
|
||||
<script src="${base!}/assets/platform/plugins/vxe/xe-utils.js"></script>
|
||||
<script src="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.js"></script>
|
||||
<script src="${base!}/assets/platform/plugins/vxe/vue-table.js"></script>
|
||||
|
||||
<!-- 引入 form-create 和 designer -->
|
||||
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user