package com.kingdee.eas.custom.perfweb.utils; import com.alibaba.fastjson.JSON; import com.kingdee.bos.BOSException; import com.kingdee.bos.Context; import com.kingdee.bos.metadata.entity.*; import com.kingdee.bos.metadata.query.util.CompareType; import com.kingdee.shr.perfweb.app.base.evalplan.*; import com.kingdee.shr.perfweb.app.workflow.GradeTaskNodeInstInfo; import com.lark.oapi.Client; import com.lark.oapi.core.Config; import com.lark.oapi.core.Transport; import com.lark.oapi.core.request.RequestOptions; import com.lark.oapi.core.response.RawResponse; import com.lark.oapi.core.token.AccessTokenType; import com.lark.oapi.core.utils.Jsons; import com.lark.oapi.core.utils.Sets; import com.lark.oapi.service.auth.v3.model.InternalTenantAccessTokenReq; import com.lark.oapi.service.auth.v3.model.InternalTenantAccessTokenReqBody; import com.lark.oapi.service.auth.v3.model.InternalTenantAccessTokenResp; import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; import org.springframework.util.CollectionUtils; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.*; /** * 飞书消息/待办推送工具类 *

* 提供: * 1. sendInitialFeishuTodos — 启动考核后推送飞书消息(按处理人去重) * 2. sendAfterScoreSubmit — 评分提交后向下一节点处理人推送飞书消息(按处理人去重) * 3. sendRemindMessage / sendMessage — 飞书 SDK 自定义 Bot 消息 * 4. sendFeishuCall / sendFeishuTodo — 时创意 OA 飞书待办接口(新建/修改/办结) * 5. sendAfterScoreSubmitOaTodo — 评分提交后 OA 待办处理(修改/新建/办结) * 6. sendCustomApprovalBotMessage / sendTodoApprovalBotMessage — 飞书开放平台审批 Bot API * 7. updateApprovalBotMessage — 飞书开放平台更新审批 Bot 消息状态 * * @author 青梧 * @date 2026-06-11 */ public class FeishuMessageUtil { private static Logger logger = Logger.getLogger(FeishuMessageUtil.class); // ===== 飞书开放平台配置(HR流程)===== /** * 正式环境 App ID */ public static String FEISHU_APP_ID_PROD = "cli_a9c200a401389cbb"; /** * 正式环境 App Secret */ public static String FEISHU_APP_SECRET_PROD = "Pho6oFfaFT2CRBB8HyWHnfhigokwtjMD"; /** * 测试环境 App ID */ public static String FEISHU_APP_ID_TEST = "cli_a9ca684911b81cc5"; /** * 测试环境 App Secret */ public static String FEISHU_APP_SECRET_TEST = "oL2rxjpbKCpwl5vnCxn5PLiDy8A1IR7Q"; /** * 当前生效 App ID(发版切换 PROD / TEST) */ public static String FEISHU_APP_ID = FEISHU_APP_ID_PROD; /** * 当前生效 App Secret */ public static String FEISHU_APP_SECRET = FEISHU_APP_SECRET_PROD; private static final String APPROVAL_BOT_SEND_PATH = "/open-apis/approval/v1/message/send"; private static final String APPROVAL_BOT_UPDATE_PATH = "/open-apis/approval/v1/message/update"; /** * 通用模板-收到审批待办(支持快捷审批) */ public static final String TEMPLATE_APPROVAL_TODO = "1008"; /** * 审批待办模板(approval_name + comment + content.summaries) */ public static final String TEMPLATE_APPROVAL_COMMENT = "1001"; /** * 自定义模板(custom_title + custom_content;sendCustomApprovalBotMessage 使用) */ public static final String TEMPLATE_CUSTOM = "1021"; /** * 更新消息-已处理 */ public static final String BOT_STATUS_PROCESSED = "PROCESSED"; /** * 更新消息-已同意 */ public static final String BOT_STATUS_APPROVED = "APPROVED"; /** * 更新消息-自定义状态 */ public static final String BOT_STATUS_CUSTOM = "CUSTOM"; /** * 启动考核 / 评分提交等绩效评定类飞书消息卡片标题(不使用流程定义名如「一级考评流程」) */ public static final String PERF_SCORE_TODO_TITLE = "\u7EE9\u6548\u8BC4\u5B9A\u5F85\u529E\u63D0\u9192"; private static volatile Client feishuClient; private static volatile Config feishuConfig; private static volatile String cachedTenantAccessToken; private static volatile long tokenExpireAtMs; private static final Object FEISHU_INIT_LOCK = new Object(); private static final Object TOKEN_LOCK = new Object(); /** * flowId(GradeTaskNodeInst.id) → 飞书 message_id,用于后续更新 Bot 消息 */ private static final Map FLOW_MESSAGE_ID_CACHE = new HashMap<>(); // ===== 时创意 OA 待办接口默认参数 ===== private static final String DEFAULT_SYSCODE = "SHR"; private static final String DEFAULT_NODENAME = "sHR"; /** * 0-待审批(新建待办) */ public static final String ISREMARK_NEW = "0"; /** * 2-已办(修改待办状态) */ public static final String ISREMARK_DONE = "2"; /** * 4-办结(流程结束) */ public static final String ISREMARK_COMPLETE = "4"; /** * 0-未读 */ public static final String VIEWTYPE_UNREAD = "0"; /** * 1-已读 */ public static final String VIEWTYPE_READ = "1"; /** * 启动考核后向一级节点处理人发送飞书消息(SDK 自定义 Bot 消息) *

* 去重逻辑:勾选多个考核对象时,若处理人相同,只发送一条消息 * * @param ctx 上下文 * @param evaObjHandlerMap key=evaObjId$workFlowId, value=处理人工号集合 * @param evaObjSet 成功的考核对象ID集合 * @param period 考核周期 * @param pcurl PC 端跳转地址 * @param workFlowName 流程名称(如 "绩效考核") * @param appurl 移动端跳转地址 */ public static void sendInitialFeishuTodos(Context ctx, Map> evaObjHandlerMap, Set evaObjSet, PerfPeriodInfo period, String pcurl, String workFlowName, String appurl) { Set uniqueHandlers = collectUniqueHandlers(evaObjHandlerMap, evaObjSet); if (uniqueHandlers.isEmpty()) { logger.error("无处理人信息,跳过飞书消息推送"); return; } String title = PERF_SCORE_TODO_TITLE; String periodName = period != null ? period.getName() : ""; Map displayMap = loadEvaObjDisplayMap(ctx, evaObjSet); String periodId = period != null && period.getId() != null ? period.getId().toString() : ""; logger.error("启动考核飞书消息推送参数:periodId=" + periodId + ",workFlowName=" + workFlowName + ",title=" + title + ",handlerCount=" + uniqueHandlers.size() + ",handlers=" + uniqueHandlers + ",pcurl=" + pcurl + ",appurl=" + appurl); for (String handlerId : uniqueHandlers) { String content = buildRemindContentForHandler(handlerId, evaObjHandlerMap, evaObjSet, displayMap, periodName); String uuid = "start_eva_" + periodId + "_" + handlerId + "_" + System.currentTimeMillis(); ApprovalBotResult result = sendCustomApprovalBotMessage(handlerId, uuid, title, content, pcurl, appurl, null); if (!result.isSuccess()) { logger.error("启动考核飞书消息发送失败,handler=" + handlerId + ",code=" + result.getCode() + ",msg=" + result.getMsg()); } else { logger.error("启动考核飞书消息发送成功,handler=" + handlerId + ",messageId=" + result.getMessageId()); } } } /** * 从 evaObjHandlerMap 中收集去重后的处理人工号 *

* 仅统计 evaObjSet 中启动成功的考核对象 * * @param evaObjHandlerMap key=evaObjId$workFlowId, value=处理人工号集合 * @param evaObjSet 成功的考核对象ID集合 * @return 去重后的处理人工号集合(保持插入顺序) */ private static Set collectUniqueHandlers(Map> evaObjHandlerMap, Set evaObjSet) { Set handlers = new LinkedHashSet<>(); if (evaObjHandlerMap == null || CollectionUtils.isEmpty(evaObjSet)) { return handlers; } for (Map.Entry> entry : evaObjHandlerMap.entrySet()) { String evaObjId = parseEvaObjId(entry.getKey()); if (StringUtils.isBlank(evaObjId) || !evaObjSet.contains(evaObjId)) { continue; } if (CollectionUtils.isEmpty(entry.getValue())) { continue; } for (String handlerId : entry.getValue()) { if (StringUtils.isNotBlank(handlerId)) { handlers.add(handlerId); } } } return handlers; } /** * 启动考核后向一级节点处理人发送 OA 飞书待办(按考核对象逐条,flowId=GradeTaskNodeInst.id) * * @param ctx 上下文 * @param evaObjHandlerMap key=evaObjId$workFlowId, value=处理人工号集合 * @param evaObjSet 成功的考核对象ID集合 * @param period 考核周期 * @param pcurl PC 端跳转地址 * @param workFlowName 流程名称 * @param appurl 移动端跳转地址 * @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入) */ public static void sendInitialFeishuTodosOa(Context ctx, Map> evaObjHandlerMap, Set evaObjSet, PerfPeriodInfo period, String pcurl, String workFlowName, String appurl, String creatorId) { if (StringUtils.isBlank(creatorId)) { logger.error("creatorId 不能为空,跳过 OA 飞书待办推送"); return; } if (evaObjHandlerMap == null || evaObjHandlerMap.isEmpty()) { logger.error("无处理人信息,跳过 OA 飞书待办推送"); return; } String requestName = buildScoreRequestName(period); for (Map.Entry> entry : evaObjHandlerMap.entrySet()) { String evaObjId = parseEvaObjId(entry.getKey()); if (StringUtils.isBlank(evaObjId) || !evaObjSet.contains(evaObjId)) { continue; } String flowId = PerfEvaObjectUtil.getGradeTaskNodeInstId(ctx, evaObjId, 1); if (StringUtils.isBlank(flowId)) { logger.error("未找到一级节点实例,跳过 OA 待办,evaObjId=" + evaObjId); continue; } sendToHandlers(entry.getValue(), requestName, pcurl, workFlowName, appurl, flowId, VIEWTYPE_UNREAD, ISREMARK_NEW, creatorId); } } /** * 评分提交后向下一节点处理人发送飞书消息(SDK 自定义 Bot 消息) *

* 去重逻辑:批量提交多个考核对象时,若下一节点处理人相同,只发送一条消息 *

* 流程结束时(无下一节点处理人)不推送消息 * * @param ctx 上下文(保留参数,便于扩展) * @param evaObjSubmitWfLevelMap key=evaObjId, value=当前提交的节点等级 wfLevel * @param nextHandlerMap key=evaObjId$workFlowId, value=下一节点处理人工号集合 * @param evaObjSet 提交成功的考核对象ID集合 * @param period 考核周期 * @param pcurl PC 端跳转地址 * @param workFlowName 流程名称 * @param appurl 移动端跳转地址 */ public static void sendAfterScoreSubmit(Context ctx, Map evaObjSubmitWfLevelMap, Map> nextHandlerMap, Set evaObjSet, PerfPeriodInfo period, String pcurl, String workFlowName, String appurl) { if (evaObjSet == null || evaObjSet.isEmpty() || period == null) { return; } Set nextHandlers = collectUniqueNextHandlers(evaObjSet, nextHandlerMap); if (nextHandlers.isEmpty()) { logger.error("评分提交后无下一节点处理人,跳过飞书消息推送"); return; } String title = PERF_SCORE_TODO_TITLE; String periodName = period.getName(); Map displayMap = loadEvaObjDisplayMap(ctx, evaObjSet); String periodId = period.getId() != null ? period.getId().toString() : ""; logger.error("评分提交飞书消息推送参数:periodId=" + periodId + ",workFlowName=" + workFlowName + ",title=" + title + ",evaObjCount=" + evaObjSet.size() + ",handlerCount=" + nextHandlers.size() + ",handlers=" + nextHandlers + ",pcurl=" + pcurl + ",appurl=" + appurl); for (String handlerId : nextHandlers) { String content = buildRemindContentForHandler(handlerId, nextHandlerMap, evaObjSet, displayMap, periodName); String uuid = "score_next_" + periodId + "_" + handlerId + "_" + System.currentTimeMillis(); ApprovalBotResult result = sendCustomApprovalBotMessage(handlerId, uuid, title, content, pcurl, appurl, null); if (!result.isSuccess()) { logger.error("评分提交飞书消息发送失败,handler=" + handlerId + ",code=" + result.getCode() + ",msg=" + result.getMsg()); } else { logger.error("评分提交飞书消息发送成功,handler=" + handlerId + ",messageId=" + result.getMessageId()); } } } /** * 收集下一节点去重后的处理人工号 * * @param evaObjSet 提交成功的考核对象ID集合 * @param nextHandlerMap 下一节点处理人 Map * @return 去重后的处理人工号集合 */ private static Set collectUniqueNextHandlers(Set evaObjSet, Map> nextHandlerMap) { Set handlers = new LinkedHashSet<>(); if (CollectionUtils.isEmpty(evaObjSet)) { return handlers; } for (String evaObjId : evaObjSet) { Set objHandlers = findHandlersForEvaObj(evaObjId, nextHandlerMap); if (!CollectionUtils.isEmpty(objHandlers)) { for (String handlerId : objHandlers) { if (StringUtils.isNotBlank(handlerId)) { handlers.add(handlerId); } } } } return handlers; } /** * 从处理人 Map 中查找指定考核对象的下一节点处理人工号 *

* handlerMap 的 key 格式为 evaObjId$workFlowId,按 evaObjId 前缀匹配并合并所有处理人 * * @param evaObjId 考核对象ID * @param handlerMap key=evaObjId$workFlowId, value=处理人工号集合 * @return 该考核对象对应的处理人工号集合(去重,可能为空) */ private static Set findHandlersForEvaObj(String evaObjId, Map> handlerMap) { Set handlers = new LinkedHashSet<>(); if (handlerMap == null || StringUtils.isBlank(evaObjId)) { return handlers; } String prefix = evaObjId + "$"; for (Map.Entry> entry : handlerMap.entrySet()) { if (entry.getKey() != null && entry.getKey().startsWith(prefix) && !CollectionUtils.isEmpty(entry.getValue())) { handlers.addAll(entry.getValue()); } } return handlers; } /** * 批量加载考核对象展示信息:key=evaObjId,value=[部门, 工号, 姓名] */ private static Map loadEvaObjDisplayMap(Context ctx, Set evaObjSet) { Map result = new HashMap<>(); if (ctx == null || CollectionUtils.isEmpty(evaObjSet)) { return result; } try { FilterInfo filter = new FilterInfo(); filter.getFilterItems().add(new FilterItemInfo("id", evaObjSet, CompareType.INCLUDE)); EntityViewInfo evi = new EntityViewInfo(); evi.setFilter(filter); SelectorItemCollection sic = new SelectorItemCollection(); sic.add(new SelectorItemInfo("id")); sic.add(new SelectorItemInfo("person.number")); sic.add(new SelectorItemInfo("person.name")); sic.add(new SelectorItemInfo("adminOrgUnit.name")); evi.setSelector(sic); PerfEvaObjectCollection coll = PerfEvaObjectFactory.getLocalInstance(ctx).getPerfEvaObjectCollection(evi); if (coll == null) { return result; } for (int i = 0; i < coll.size(); i++) { PerfEvaObjectInfo info = coll.get(i); if (info == null || info.getId() == null) { continue; } String deptName = info.getAdminOrgUnit() != null ? StringUtils.defaultString(info.getAdminOrgUnit().getName(), "") : ""; String personNumber = ""; String personName = ""; if (info.getPerson() != null) { personNumber = StringUtils.defaultString(info.getPerson().getNumber(), ""); personName = StringUtils.defaultString(info.getPerson().getName(ctx.getLocale()), ""); } result.put(info.getId().toString(), new String[]{deptName, personNumber, personName}); } } catch (BOSException e) { logger.error("loadEvaObjDisplayMap failed", e); } return result; } /** * 按处理人拼装提醒正文:取该处理人名下、勾选顺序中第一条考核对象的信息 */ private static String buildRemindContentForHandler(String handlerId, Map> handlerMap, Set evaObjSet, Map displayMap, String periodName) { if (StringUtils.isBlank(handlerId) || CollectionUtils.isEmpty(evaObjSet)) { return buildEvaResultSummaryRemindMessage("", "", "", periodName); } for (String evaObjId : evaObjSet) { Set handlers = findHandlersForEvaObj(evaObjId, handlerMap); if (CollectionUtils.isEmpty(handlers) || !handlers.contains(handlerId)) { continue; } String[] display = displayMap != null ? displayMap.get(evaObjId) : null; if (display != null) { return buildEvaResultSummaryRemindMessage(display[0], display[1], display[2], periodName); } return buildEvaResultSummaryRemindMessage("", "", "", periodName); } return buildEvaResultSummaryRemindMessage("", "", "", periodName); } /** * 从 evaObjHandlerMap 的 key 中解析考核对象ID *

* key 格式为 evaObjId$workFlowId,取 $ 前面的部分;无 $ 时返回原 key * * @param key evaObjHandlerMap 的 key * @return 考核对象ID;key 为空时返回 null */ private static String parseEvaObjId(String key) { if (StringUtils.isBlank(key)) { return null; } int idx = key.indexOf("$"); return idx > 0 ? key.substring(0, idx) : key; } /** * 发送飞书提醒消息(SDK 自定义 Bot 消息) * * @param receiverId 接收人工号(飞书 user_id,与现有 OA 接口一致) * @param title 消息标题 * @param content 消息正文 * @param pcurl PC 端跳转地址 * @param appurl 移动端跳转地址 * @param uuid 幂等 ID * @return 调用结果 */ public static ApprovalBotResult sendRemindMessage(String receiverId, String title, String content, String pcurl, String appurl, String uuid) { if (StringUtils.isBlank(receiverId)) { ApprovalBotResult result = new ApprovalBotResult(); result.code = -1; result.msg = "receiverId 不能为空"; return result; } String safeTitle = StringUtils.defaultIfBlank(title, "绩效考核提醒"); String safeUuid = StringUtils.isNotBlank(uuid) ? uuid : String.valueOf(System.currentTimeMillis()); logger.error("飞书 sendRemindMessage 参数:receiverId=" + receiverId + ",title=" + safeTitle + ",content=" + content + ",pcurl=" + pcurl + ",appurl=" + appurl + ",uuid=" + safeUuid); return sendCustomApprovalBotMessage(receiverId, safeUuid, safeTitle, content, pcurl, appurl, null); } /** * 发送飞书消息(兼容旧调用签名) * * @param receiverId 接收人工号 * @param content 消息正文 * @param pcurl PC 端跳转地址 * @param workFlowName 消息标题(流程名称) * @param appurl 移动端跳转地址 * @param uuid 幂等 ID(可传 flowId 或业务唯一键) * @param receivets 保留参数,兼容旧调用 * @param receivedatetime 保留参数,兼容旧调用 * @return 调用结果 */ public static ApprovalBotResult sendMessage(String receiverId, String content, String pcurl, String workFlowName, String appurl, String uuid, String receivets, String receivedatetime) { return sendRemindMessage(receiverId, workFlowName, content, pcurl, appurl, uuid); } /** * 拼装启动考核/评分提交飞书消息正文 *

* 消息格式: *

     * 您(工号:{personNumber},姓名:{personName}){periodDisplay}绩效尚未完成审批评定
     * 请点击此消息进入 HR 系统完成绩效等级评定。
     * 
*

* 卡片标题(custom_title)使用 {@link #PERF_SCORE_TODO_TITLE},流程名称 workFlowName 仅用于日志排查。 * * @param deptName 保留参数兼容调用方,消息正文不再展示部门 * @param personNumber 被考核人工号 * @param personName 被考核人姓名 * @param periodDisplay 考核周期展示文本(如 "SCY-2026年02季度") * @return 完整飞书待办消息正文(含换行) */ public static String buildEvaResultSummaryRemindMessage(String deptName, String personNumber, String personName, String periodDisplay) { String number = StringUtils.defaultString(personNumber, ""); String name = StringUtils.defaultString(personName, ""); String period = StringUtils.defaultString(periodDisplay, ""); return "您组织下的(工号:" + number + ",姓名:" + name + ")" + period + "绩效尚未完成审批评定\n" + "请点击此消息进入 HR 系统完成绩效等级评定。"; } /** * 绩效结果审批通过通知正文(保留模板,当前无业务调用) * * @param periodDisplay 考核周期名称 * @param gradeLevel 无等级时传空,展示为「—」 */ public static String buildEvaResultApprovedNotifyMessage(String periodDisplay, String gradeLevel) { String period = StringUtils.defaultString(periodDisplay, ""); String grade = StringUtils.isNotBlank(gradeLevel) ? gradeLevel : "—"; return "【绩效结果通知】\n" + "您" + period + "绩效考核已审批通过,等级为 " + grade + "。\n" + "请点击此消息进入 HR 系统查看详情。"; } /** * 考核结果汇总列表「发送飞书消息」按钮专用模板(sendFeishuToLeadersAction) * * @param periodDisplay 考核周期名称(如「2026 年 一 季度」) */ public static String buildEvaResultSummaryFeishuMessage(String periodDisplay) { String period = StringUtils.defaultString(periodDisplay, ""); return "【绩效结果公示通知】您的" + period + "绩效考核结果已出,可前往 HR 系统查看。"; } // ===== 时创意 OA 飞书待办通道(保留) ===== /** * 拼装绩效考核 OA 待办标题 * * @param period 考核周期 * @return 待办标题 */ public static String buildScoreRequestName(PerfPeriodInfo period) { String periodName = period != null ? period.getName() : ""; return "绩效考核待办:【" + periodName + "】考核周期内存在考核任务待处理,请前往绩效工作台进行评分!!"; } /** * 评分提交后 OA 飞书待办处理(修改上一级/新建下一级/办结) *

* flowId 均取 GradeTaskNodeInst.id * * @param ctx 上下文 * @param evaObjSubmitWfLevelMap key=evaObjId, value=当前提交的节点等级 wfLevel * @param nextHandlerMap key=evaObjId$workFlowId, value=下一节点处理人工号集合 * @param evaObjSet 提交成功的考核对象ID集合 * @param period 考核周期 * @param pcurl PC端跳转地址 * @param workFlowName 流程名称 * @param appurl 移动端跳转地址 * @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入) */ public static void sendAfterScoreSubmitOaTodo(Context ctx, Map evaObjSubmitWfLevelMap, Map> nextHandlerMap, Set evaObjSet, PerfPeriodInfo period, String pcurl, String workFlowName, String appurl, String creatorId) { if (StringUtils.isBlank(creatorId)) { logger.error("creatorId 不能为空,跳过 OA 飞书待办处理"); return; } if (evaObjSet == null || evaObjSet.isEmpty() || period == null) { return; } String requestName = buildScoreRequestName(period); for (String evaObjId : evaObjSet) { Integer submitWfLevel = evaObjSubmitWfLevelMap != null ? evaObjSubmitWfLevelMap.get(evaObjId) : null; if (submitWfLevel == null) { continue; } try { if (submitWfLevel != 1) { modifyPreviousTodo(ctx, evaObjId, submitWfLevel, requestName, pcurl, workFlowName, appurl, creatorId); } Set nextHandlers = findHandlersForEvaObj(evaObjId, nextHandlerMap); if (!CollectionUtils.isEmpty(nextHandlers)) { String nextFlowId = PerfEvaObjectUtil.getGradeTaskNodeInstId(ctx, evaObjId, submitWfLevel + 1); if (StringUtils.isBlank(nextFlowId)) { logger.error("未找到下一节点实例,跳过 OA 新待办,evaObjId=" + evaObjId + ", wfLevel=" + (submitWfLevel + 1)); continue; } sendToHandlers(nextHandlers, requestName, pcurl, workFlowName, appurl, nextFlowId, VIEWTYPE_UNREAD, ISREMARK_NEW, creatorId); } else { completeCurrentTodo(ctx, evaObjId, submitWfLevel, requestName, pcurl, workFlowName, appurl, creatorId); } } catch (Exception e) { logger.error("评分提交 OA 待办处理失败,evaObjId=" + evaObjId, e); } } } /** * 修改上一级 OA 飞书待办为已办(viewtype=1,isremark=2) */ private static void modifyPreviousTodo(Context ctx, String evaObjId, int submitWfLevel, String requestName, String pcurl, String workFlowName, String appurl, String creatorId) throws BOSException { GradeTaskNodeInstInfo prevInst = PerfEvaObjectUtil.getGradeTaskNodeInst(ctx, evaObjId, String.valueOf(submitWfLevel - 1)); if (prevInst == null || prevInst.getId() == null) { logger.error("未找到上一级节点实例,跳过修改 OA 待办,evaObjId=" + evaObjId + ", wfLevel=" + (submitWfLevel - 1)); return; } String receiverId = PerfEvaObjectUtil.getNodeHandlerNumber(prevInst); if (StringUtils.isBlank(receiverId)) { logger.error("上一级节点无处理人,跳过修改 OA 待办,evaObjId=" + evaObjId); return; } sendFeishuCall(receiverId, requestName, pcurl, workFlowName, appurl, prevInst.getId().toString(), VIEWTYPE_READ, ISREMARK_DONE, creatorId); } /** * 流程结束时办结当前节点 OA 飞书待办(viewtype=1,isremark=4) */ private static void completeCurrentTodo(Context ctx, String evaObjId, int submitWfLevel, String requestName, String pcurl, String workFlowName, String appurl, String creatorId) throws BOSException { GradeTaskNodeInstInfo currentInst = PerfEvaObjectUtil.getGradeTaskNodeInst(ctx, evaObjId, String.valueOf(submitWfLevel)); if (currentInst == null || currentInst.getId() == null) { logger.error("未找到当前节点实例,跳过 OA 办结,evaObjId=" + evaObjId + ", wfLevel=" + submitWfLevel); return; } String receiverId = PerfEvaObjectUtil.getNodeHandlerNumber(currentInst); if (StringUtils.isBlank(receiverId)) { logger.error("当前节点无处理人,跳过 OA 办结,evaObjId=" + evaObjId); return; } sendFeishuCall(receiverId, requestName, pcurl, workFlowName, appurl, currentInst.getId().toString(), VIEWTYPE_READ, ISREMARK_COMPLETE, creatorId); } /** * 向多个处理人批量发送 OA 飞书待办/修改/办结 */ private static void sendToHandlers(Set handlers, String requestName, String pcurl, String workFlowName, String appurl, String flowId, String viewtype, String isremark, String creatorId) { if (CollectionUtils.isEmpty(handlers) || StringUtils.isBlank(flowId)) { return; } for (String handlerId : handlers) { if (StringUtils.isNotBlank(handlerId)) { sendFeishuCall(handlerId, requestName, pcurl, workFlowName, appurl, flowId, viewtype, isremark, creatorId); } } } /** * 发送 OA 飞书待办(新建,viewtype=0,isremark=0) * * @param receiverId 接收人工号 * @param requestName 待办标题 * @param pcurl PC 端跳转地址 * @param workFlowName 流程名称 * @param appurl 移动端跳转地址 * @param flowId 流程 ID(GradeTaskNodeInst.id) * @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入) */ public static void sendFeishuTodo(String receiverId, String requestName, String pcurl, String workFlowName, String appurl, String flowId, String creatorId) { sendFeishuCall(receiverId, requestName, pcurl, workFlowName, appurl, flowId, VIEWTYPE_UNREAD, ISREMARK_NEW, creatorId); } /** * 发送 OA 飞书待办/修改/办结(可指定 viewtype、isremark) *

* - 新建待办:viewtype=0, isremark=0 * - 修改待办:viewtype=1, isremark=2 * - 流程办结:viewtype=1, isremark=4 * * @param receiverId 接收人工号 * @param requestName 待办标题 * @param pcurl PC 端跳转地址 * @param workFlowName 流程名称 * @param appurl 移动端跳转地址 * @param flowId 流程 ID(GradeTaskNodeInst.id) * @param viewtype 查看状态(0 未读/1 已读) * @param isremark 待办状态(0 待审批/2 已办/4 办结) * @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入) */ public static void sendFeishuCall(String receiverId, String requestName, String pcurl, String workFlowName, String appurl, String flowId, String viewtype, String isremark, String creatorId) { if (StringUtils.isBlank(creatorId)) { logger.error("OA 飞书待办 creatorId 不能为空,receiverId=" + receiverId + ", flowId=" + flowId); return; } if (StringUtils.isBlank(receiverId) || StringUtils.isBlank(flowId)) { logger.error("OA 飞书待办参数不完整,receiverId=" + receiverId + ", flowId=" + flowId); return; } String receivets = String.valueOf(System.currentTimeMillis()); String nowStr = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); String apiUrl = UrlParamUtil.PRODURL; String safePcurl = StringUtils.defaultString(pcurl); String safeAppurl = StringUtils.isNotBlank(appurl) ? appurl : safePcurl; String jsonBody = buildOaTodoRequestJson(receiverId, requestName, safePcurl, workFlowName, safeAppurl, flowId, receivets, nowStr, viewtype, isremark, creatorId); logger.error("OA 飞书待办请求 URL=" + apiUrl); logger.error("OA 飞书待办请求 Body=" + jsonBody); HttpURLConnection conn = null; try { URL url = new URL(apiUrl); conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); conn.setDoOutput(true); conn.setConnectTimeout(10000); conn.setReadTimeout(10000); OutputStream os = conn.getOutputStream(); os.write(jsonBody.getBytes("UTF-8")); os.flush(); os.close(); int responseCode = conn.getResponseCode(); logger.error("OA 飞书待办响应码=" + responseCode + ",receiverId=" + receiverId + ",flowId=" + flowId + ",viewtype=" + viewtype + ",isremark=" + isremark); } catch (Exception e) { logger.error("OA 飞书待办推送异常,receiverId=" + receiverId + ",flowId=" + flowId, e); } finally { if (conn != null) { conn.disconnect(); } } } /** * 构建 OA 飞书待办请求 JSON */ private static String buildOaTodoRequestJson(String receiverId, String requestName, String pcurl, String workFlowName, String appurl, String flowId, String receivets, String receivedatetime, String viewtype, String isremark, String creatorId) { return "{" + "\"requestname\":\"" + escapeJson(requestName) + "\"," + "\"creator\":\"" + escapeJson(creatorId) + "\"," + "\"receiver\":\"" + escapeJson(receiverId) + "\"," + "\"pcurl\":\"" + escapeJson(pcurl) + "\"," + "\"workflowname\":\"" + escapeJson(workFlowName) + "\"," + "\"viewtype\":\"" + escapeJson(viewtype) + "\"," + "\"appurl\":\"" + escapeJson(appurl) + "\"," + "\"syscode\":\"" + DEFAULT_SYSCODE + "\"," + "\"nodename\":\"" + DEFAULT_NODENAME + "\"," + "\"receivets\":\"" + escapeJson(receivets) + "\"," + "\"receivedatetime\":\"" + escapeJson(receivedatetime) + "\"," + "\"isremark\":\"" + escapeJson(isremark) + "\"," + "\"createdatetime\":\"" + escapeJson(receivedatetime) + "\"," + "\"flowid\":\"" + escapeJson(flowId) + "\"}"; } /** * JSON 字符串转义 */ private static String escapeJson(String str) { if (str == null) { return ""; } return str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); } // ===== 飞书开放平台官方 API(SDK Transport)===== /** * 飞书审批 Bot 消息调用结果 */ public static class ApprovalBotResult { private int code; private String msg; private String messageId; public int getCode() { return code; } public String getMsg() { return msg; } public String getMessageId() { return messageId; } public boolean isSuccess() { return code == 0; } } /** * 获取飞书 SDK Config(单例) * * @return Config 实例;AppId/AppSecret 未配置时返回 null */ private static Config getFeishuConfig() { if (feishuConfig != null) { return feishuConfig; } synchronized (FEISHU_INIT_LOCK) { if (feishuConfig != null) { return feishuConfig; } if (StringUtils.isBlank(FEISHU_APP_ID) || StringUtils.isBlank(FEISHU_APP_SECRET)) { logger.error("飞书 AppId/AppSecret 未配置,无法创建 SDK Config"); return null; } Config config = new Config(); config.setAppId(FEISHU_APP_ID); config.setAppSecret(FEISHU_APP_SECRET); feishuConfig = config; if (feishuClient == null) { feishuClient = Client.newBuilder(FEISHU_APP_ID, FEISHU_APP_SECRET).build(); } return feishuConfig; } } /** * 获取飞书 SDK Client(单例) * * @return Client 实例;AppId/AppSecret 未配置时返回 null */ private static Client getFeishuClient() { if (feishuClient != null) { return feishuClient; } getFeishuConfig(); return feishuClient; } /** * 构建 SDK 请求选项(自动附带 tenant_access_token) * * @return RequestOptions */ private static RequestOptions buildRequestOptions() { String token = getTenantAccessToken(); RequestOptions.Builder builder = RequestOptions.newBuilder(); if (StringUtils.isNotBlank(token)) { builder.tenantAccessToken(token); } return builder.build(); } /** * 获取 tenant_access_token(飞书 SDK + 内存缓存,过期前 60 秒自动刷新) *

* 接口文档:https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal * * @return tenant_access_token;失败返回 null */ public static String getTenantAccessToken() { long now = System.currentTimeMillis(); if (StringUtils.isNotBlank(cachedTenantAccessToken) && now < tokenExpireAtMs - 60000) { return cachedTenantAccessToken; } synchronized (TOKEN_LOCK) { now = System.currentTimeMillis(); if (StringUtils.isNotBlank(cachedTenantAccessToken) && now < tokenExpireAtMs - 60000) { return cachedTenantAccessToken; } Client client = getFeishuClient(); if (client == null) { return null; } try { InternalTenantAccessTokenReq req = InternalTenantAccessTokenReq.newBuilder().internalTenantAccessTokenReqBody(InternalTenantAccessTokenReqBody.newBuilder().appId(FEISHU_APP_ID).appSecret(FEISHU_APP_SECRET).build()).build(); InternalTenantAccessTokenResp resp = client.auth().v3().tenantAccessToken().internal(req); if (!resp.success()) { logger.error("获取 tenant_access_token 失败,code=" + resp.getCode() + ",msg=" + resp.getMsg() + ",reqId=" + resp.getRequestId()); return null; } String response = new String(resp.getRawResponse().getBody(), StandardCharsets.UTF_8); String token = extractJsonString(response, "tenant_access_token"); if (StringUtils.isBlank(token)) { logger.error("获取 tenant_access_token 失败,响应中无 token,response=" + response); return null; } int expire = extractJsonInt(response, "expire", 7200); cachedTenantAccessToken = token; tokenExpireAtMs = System.currentTimeMillis() + expire * 1000L; return token; } catch (Exception e) { logger.error("飞书 SDK 获取 tenant_access_token 异常", e); return null; } } } /** * 调用飞书审批 Bot 发送接口(SDK Transport) * * @param body 请求体(template_id、user_id 等) * @return 调用结果 */ private static ApprovalBotResult sendApprovalBotMessage(Map body) { return invokeApprovalBotApi(APPROVAL_BOT_SEND_PATH, body); } /** * 调用飞书审批 Bot 更新接口(SDK Transport) * * @param body 请求体(message_id、status 等) * @return 调用结果 */ private static ApprovalBotResult updateApprovalBotMessageInternal(Map body) { return invokeApprovalBotApi(APPROVAL_BOT_UPDATE_PATH, body); } /** * 通过 SDK Transport 调用审批 Bot OpenAPI * * @param path 接口路径 * @param body 请求 JSON 对象 * @return 调用结果 */ private static ApprovalBotResult invokeApprovalBotApi(String path, Map body) { ApprovalBotResult result = new ApprovalBotResult(); Config config = getFeishuConfig(); if (config == null) { result.code = -1; result.msg = "飞书 AppId/AppSecret 未配置"; return result; } try { logger.error("飞书 SDK 请求 path=" + path + ",requestBody=" + Jsons.DEFAULT.toJson(body)); RequestOptions reqOpt = buildRequestOptions(); RawResponse raw = Transport.send(config, reqOpt, "POST", path, Sets.newHashSet(AccessTokenType.Tenant), body); return parseApprovalBotRawResponse(raw, path); } catch (Exception e) { logger.error("飞书 SDK 调用审批 Bot 接口异常,path=" + path, e); result.code = -1; result.msg = e.getMessage(); return result; } } /** * 发送审批 Bot 消息(template_id=1021 自定义模板) *

* 请求体结构:custom_title、custom_content、note、actions;Bot 跳转 url 使用 appurl,pc_url 使用 pcurl *

* 接口文档:https://open.feishu.cn/document/server-docs/approval-v4/message/send-bot-messages * * @param userId 接收人 user_id(当前与工号一致) * @param uuid 幂等 ID,相同 uuid 1 小时内只发一次 * @param title 卡片标题 → custom_title * @param content 卡片正文 → custom_content(支持 Markdown) * @param pcUrl PC 端跳转地址 * @param mobileUrl 移动端跳转地址 * @param note 底部备注;为空时默认「来自 HR 绩效考核系统」 * @return 调用结果,成功时 messageId 可用于 updateApprovalBotMessage */ public static ApprovalBotResult sendCustomApprovalBotMessage(String userId, String uuid, String title, String content, String pcUrl, String mobileUrl, String note) { ApprovalBotResult result = new ApprovalBotResult(); if (StringUtils.isBlank(userId)) { result.code = -1; result.msg = "userId 不能为空"; return result; } String safeUuid = StringUtils.isNotBlank(uuid) ? uuid : String.valueOf(System.currentTimeMillis()); String safeNote = StringUtils.defaultString(note, "来自 HR 绩效考核系统"); String safeMobileUrl = StringUtils.isNotBlank(mobileUrl) ? mobileUrl : pcUrl; String safePcUrl = StringUtils.isNotBlank(pcUrl) ? pcUrl : safeMobileUrl; String safeTitle = StringUtils.defaultIfBlank(title, "绩效考核提醒"); String safeContent = StringUtils.defaultIfBlank(content, safeTitle); Map texts = new LinkedHashMap<>(); texts.put("@i18n@1", safeTitle); texts.put("@i18n@2", safeContent); texts.put("@i18n@3", safeNote); texts.put("@i18n@4", "查看详情"); Map action = new LinkedHashMap<>(); action.put("action_name", "@i18n@4"); action.put("url", safeMobileUrl); action.put("android_url", safeMobileUrl); action.put("ios_url", safeMobileUrl); action.put("pc_url", safePcUrl); Map body = new LinkedHashMap<>(); body.put("template_id", TEMPLATE_CUSTOM); body.put("user_id", userId); body.put("uuid", safeUuid); body.put("custom_title", "@i18n@1"); body.put("custom_content", "@i18n@2"); body.put("note", "@i18n@3"); body.put("actions", Collections.singletonList(action)); body.put("i18n_resources", Collections.singletonList(buildI18nResource(texts))); logger.error("飞书 sendCustomApprovalBotMessage 参数:" + JSON.toJSON(body).toString()); System.out.println("飞书 sendCustomApprovalBotMessage 参数:" + JSON.toJSON(body).toString()); return sendApprovalBotMessage(body); } /** * 发送「收到审批待办」Bot 消息(template_id=1008) *

* 适用于标准待办卡片;后续可通过 updateApprovalBotMessage 更新为已办/办结 * * @param userId 接收人飞书 user_id * @param uuid 幂等 ID * @param approvalName 审批名称(如「绩效考核待办」) * @param titleUserId 标题中的申请人/被考核人 user_id * @param summary 审批事由摘要 * @param pcUrl PC 端跳转地址 * @param mobileUrl 移动端跳转地址 * @return 调用结果 */ public static ApprovalBotResult sendTodoApprovalBotMessage(String userId, String uuid, String approvalName, String titleUserId, String summary, String pcUrl, String mobileUrl) { ApprovalBotResult result = new ApprovalBotResult(); if (StringUtils.isBlank(userId)) { result.code = -1; result.msg = "userId 不能为空"; return result; } String safeUuid = StringUtils.isNotBlank(uuid) ? uuid : String.valueOf(System.currentTimeMillis()); String safeMobileUrl = StringUtils.isNotBlank(mobileUrl) ? mobileUrl : pcUrl; String safePcUrl = StringUtils.isNotBlank(pcUrl) ? pcUrl : safeMobileUrl; String safeTitleUserId = StringUtils.defaultIfBlank(titleUserId, userId); Map texts = new LinkedHashMap<>(); texts.put("@i18n@1", approvalName); texts.put("@i18n@2", summary); Map summaryItem = new LinkedHashMap<>(); summaryItem.put("summary", "@i18n@2"); Map content = new LinkedHashMap<>(); content.put("user_id", safeTitleUserId); content.put("summaries", Collections.singletonList(summaryItem)); Map action = new LinkedHashMap<>(); action.put("action_name", "DETAIL"); action.put("url", safeMobileUrl); action.put("android_url", safeMobileUrl); action.put("ios_url", safeMobileUrl); action.put("pc_url", safePcUrl); Map body = new LinkedHashMap<>(); body.put("template_id", TEMPLATE_APPROVAL_TODO); body.put("user_id", userId); body.put("uuid", safeUuid); body.put("approval_name", "@i18n@1"); body.put("title_user_id", safeTitleUserId); body.put("content", content); body.put("actions", Collections.singletonList(action)); body.put("i18n_resources", Collections.singletonList(buildI18nResource(texts))); logger.error("飞书 sendTodoApprovalBotMessage:" + body); return sendApprovalBotMessage(body); } /** * 更新审批 Bot 消息状态 *

* 注意:仅支持更新 template_id=1008 的消息,且 30 天以内 * * @param messageId 发送接口返回的 message_id * @param status 状态:PROCESSED/APPROVED/REJECTED/CUSTOM 等 * @param statusText 自定义状态文案(status=CUSTOM 时生效) * @return 调用结果 */ public static ApprovalBotResult updateApprovalBotMessage(String messageId, String status, String statusText) { ApprovalBotResult result = new ApprovalBotResult(); if (StringUtils.isBlank(messageId)) { result.code = -1; result.msg = "messageId 不能为空"; return result; } String safeStatus = StringUtils.defaultIfBlank(status, BOT_STATUS_PROCESSED); Map body = new LinkedHashMap<>(); body.put("message_id", messageId); body.put("status", safeStatus); if (BOT_STATUS_CUSTOM.equals(safeStatus) && StringUtils.isNotBlank(statusText)) { Map texts = new LinkedHashMap<>(); texts.put("@i18n@1", statusText); texts.put("@i18n@2", "查看详情"); body.put("status_name", "@i18n@1"); body.put("detail_action_name", "@i18n@2"); body.put("i18n_resources", Collections.singletonList(buildI18nResource(texts))); } else { body.put("i18n_resources", Collections.singletonList(buildI18nResource(Collections.emptyMap()))); } logger.error("飞书 updateApprovalBotMessage 参数:messageId=" + messageId + ",status=" + safeStatus + ",statusText=" + statusText); return updateApprovalBotMessageInternal(body); } /** * 缓存 flowId 与飞书 message_id 的映射,便于后续按 GradeTaskNodeInst.id 更新消息 * * @param flowId GradeTaskNodeInst.id * @param messageId 飞书 message_id */ public static void cacheFlowMessageId(String flowId, String messageId) { if (StringUtils.isNotBlank(flowId) && StringUtils.isNotBlank(messageId)) { synchronized (FLOW_MESSAGE_ID_CACHE) { FLOW_MESSAGE_ID_CACHE.put(flowId, messageId); } } } /** * 根据 flowId 获取已缓存的飞书 message_id * * @param flowId GradeTaskNodeInst.id * @return message_id;未缓存时返回 null */ public static String getCachedMessageIdByFlowId(String flowId) { if (StringUtils.isBlank(flowId)) { return null; } synchronized (FLOW_MESSAGE_ID_CACHE) { return FLOW_MESSAGE_ID_CACHE.get(flowId); } } /** * 发送自定义 Bot 消息并缓存 flowId → message_id * * @param pcUrl PC 跳转地址 * @param mobileUrl 移动端跳转地址 * @return 调用结果 */ public static ApprovalBotResult sendCustomBotAndCache(String flowId, String userId, String title, String content, String pcUrl, String mobileUrl) { ApprovalBotResult result = sendCustomApprovalBotMessage(userId, flowId, title, content, pcUrl, mobileUrl, null); if (result.isSuccess() && StringUtils.isNotBlank(flowId)) { cacheFlowMessageId(flowId, result.getMessageId()); } return result; } /** * 按 flowId 更新已缓存 Bot 消息为已处理 * * @param flowId GradeTaskNodeInst.id * @param statusText 自定义状态文案(可为空,默认「已处理」) * @return 调用结果;未找到缓存 message_id 时 code=-1 */ public static ApprovalBotResult updateBotByFlowId(String flowId, String statusText) { String messageId = getCachedMessageIdByFlowId(flowId); if (StringUtils.isBlank(messageId)) { ApprovalBotResult result = new ApprovalBotResult(); result.code = -1; result.msg = "未找到 flowId 对应的 message_id,flowId=" + flowId; return result; } if (StringUtils.isNotBlank(statusText)) { return updateApprovalBotMessage(messageId, BOT_STATUS_CUSTOM, statusText); } return updateApprovalBotMessage(messageId, BOT_STATUS_PROCESSED, null); } /** * 构建 i18n_resources 单条资源 * * @param texts i18n 文案键值对 * @return i18n 资源 Map */ private static Map buildI18nResource(Map texts) { Map resource = new LinkedHashMap<>(); resource.put("locale", "zh-CN"); resource.put("is_default", true); resource.put("texts", texts); return resource; } /** * 解析 SDK RawResponse 为 ApprovalBotResult * * @param raw SDK 原始响应 * @param path 接口路径(日志用) * @return 调用结果 */ private static ApprovalBotResult parseApprovalBotRawResponse(RawResponse raw, String path) { ApprovalBotResult result = new ApprovalBotResult(); if (raw == null || raw.getBody() == null) { result.code = -1; result.msg = "飞书 SDK 无响应,path=" + path; return result; } String response = new String(raw.getBody(), StandardCharsets.UTF_8); logger.error("飞书 SDK path=" + path + ",status=" + raw.getStatusCode() + ",body=" + response); result.code = extractJsonInt(response, "code", -1); result.msg = extractJsonString(response, "msg"); String dataMessageId = extractNestedJsonString(response, "data", "message_id"); if (StringUtils.isBlank(dataMessageId)) { dataMessageId = extractJsonString(response, "message_id"); } result.messageId = dataMessageId; return result; } private static String extractJsonString(String json, String field) { if (StringUtils.isBlank(json) || StringUtils.isBlank(field)) { return null; } String key = "\"" + field + "\":\""; int start = json.indexOf(key); if (start < 0) { return null; } start += key.length(); int end = json.indexOf("\"", start); if (end < 0) { return null; } return json.substring(start, end); } private static int extractJsonInt(String json, String field, int defaultValue) { if (StringUtils.isBlank(json) || StringUtils.isBlank(field)) { return defaultValue; } String key = "\"" + field + "\":"; int start = json.indexOf(key); if (start < 0) { return defaultValue; } start += key.length(); int end = start; while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) { end++; } if (end <= start) { return defaultValue; } try { return Integer.parseInt(json.substring(start, end)); } catch (NumberFormatException e) { return defaultValue; } } private static String extractNestedJsonString(String json, String objectField, String field) { if (StringUtils.isBlank(json)) { return null; } String objectKey = "\"" + objectField + "\":"; int objectStart = json.indexOf(objectKey); if (objectStart < 0) { return null; } objectStart = json.indexOf("{", objectStart); if (objectStart < 0) { return null; } int objectEnd = json.indexOf("}", objectStart); if (objectEnd < 0) { return null; } return extractJsonString(json.substring(objectStart, objectEnd + 1), field); } }