| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197 |
- 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.*;
- /**
- * 飞书消息/待办推送工具类
- * <p>
- * 提供:
- * 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<String, String> 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 消息)
- * <p>
- * 去重逻辑:勾选多个考核对象时,若处理人相同,只发送一条消息
- *
- * @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<String, Set<String>> evaObjHandlerMap, Set<String> evaObjSet, PerfPeriodInfo period, String pcurl, String workFlowName, String appurl) {
- Set<String> uniqueHandlers = collectUniqueHandlers(evaObjHandlerMap, evaObjSet);
- if (uniqueHandlers.isEmpty()) {
- logger.error("无处理人信息,跳过飞书消息推送");
- return;
- }
- String title = PERF_SCORE_TODO_TITLE;
- String periodName = period != null ? period.getName() : "";
- Map<String, String[]> 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 中收集去重后的处理人工号
- * <p>
- * 仅统计 evaObjSet 中启动成功的考核对象
- *
- * @param evaObjHandlerMap key=evaObjId$workFlowId, value=处理人工号集合
- * @param evaObjSet 成功的考核对象ID集合
- * @return 去重后的处理人工号集合(保持插入顺序)
- */
- private static Set<String> collectUniqueHandlers(Map<String, Set<String>> evaObjHandlerMap, Set<String> evaObjSet) {
- Set<String> handlers = new LinkedHashSet<>();
- if (evaObjHandlerMap == null || CollectionUtils.isEmpty(evaObjSet)) {
- return handlers;
- }
- for (Map.Entry<String, Set<String>> 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<String, Set<String>> evaObjHandlerMap, Set<String> 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<String, Set<String>> 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 消息)
- * <p>
- * 去重逻辑:批量提交多个考核对象时,若下一节点处理人相同,只发送一条消息
- * <p>
- * 流程结束时(无下一节点处理人)不推送消息
- *
- * @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<String, Integer> evaObjSubmitWfLevelMap, Map<String, Set<String>> nextHandlerMap, Set<String> evaObjSet, PerfPeriodInfo period, String pcurl, String workFlowName, String appurl) {
- if (evaObjSet == null || evaObjSet.isEmpty() || period == null) {
- return;
- }
- Set<String> nextHandlers = collectUniqueNextHandlers(evaObjSet, nextHandlerMap);
- if (nextHandlers.isEmpty()) {
- logger.error("评分提交后无下一节点处理人,跳过飞书消息推送");
- return;
- }
- String title = PERF_SCORE_TODO_TITLE;
- String periodName = period.getName();
- Map<String, String[]> 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<String> collectUniqueNextHandlers(Set<String> evaObjSet, Map<String, Set<String>> nextHandlerMap) {
- Set<String> handlers = new LinkedHashSet<>();
- if (CollectionUtils.isEmpty(evaObjSet)) {
- return handlers;
- }
- for (String evaObjId : evaObjSet) {
- Set<String> objHandlers = findHandlersForEvaObj(evaObjId, nextHandlerMap);
- if (!CollectionUtils.isEmpty(objHandlers)) {
- for (String handlerId : objHandlers) {
- if (StringUtils.isNotBlank(handlerId)) {
- handlers.add(handlerId);
- }
- }
- }
- }
- return handlers;
- }
- /**
- * 从处理人 Map 中查找指定考核对象的下一节点处理人工号
- * <p>
- * handlerMap 的 key 格式为 evaObjId$workFlowId,按 evaObjId 前缀匹配并合并所有处理人
- *
- * @param evaObjId 考核对象ID
- * @param handlerMap key=evaObjId$workFlowId, value=处理人工号集合
- * @return 该考核对象对应的处理人工号集合(去重,可能为空)
- */
- private static Set<String> findHandlersForEvaObj(String evaObjId, Map<String, Set<String>> handlerMap) {
- Set<String> handlers = new LinkedHashSet<>();
- if (handlerMap == null || StringUtils.isBlank(evaObjId)) {
- return handlers;
- }
- String prefix = evaObjId + "$";
- for (Map.Entry<String, Set<String>> 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<String, String[]> loadEvaObjDisplayMap(Context ctx, Set<String> evaObjSet) {
- Map<String, String[]> 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<String, Set<String>> handlerMap, Set<String> evaObjSet, Map<String, String[]> displayMap, String periodName) {
- if (StringUtils.isBlank(handlerId) || CollectionUtils.isEmpty(evaObjSet)) {
- return buildEvaResultSummaryRemindMessage("", "", "", periodName);
- }
- for (String evaObjId : evaObjSet) {
- Set<String> 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
- * <p>
- * 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);
- }
- /**
- * 拼装启动考核/评分提交飞书消息正文
- * <p>
- * 消息格式:
- * <pre>
- * 您(工号:{personNumber},姓名:{personName}){periodDisplay}绩效尚未完成审批评定
- * 请点击此消息进入 HR 系统完成绩效等级评定。
- * </pre>
- * <p>
- * 卡片标题(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 飞书待办处理(修改上一级/新建下一级/办结)
- * <p>
- * 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<String, Integer> evaObjSubmitWfLevelMap, Map<String, Set<String>> nextHandlerMap, Set<String> 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<String> 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<String> 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)
- * <p>
- * - 新建待办: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 秒自动刷新)
- * <p>
- * 接口文档: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<String, Object> body) {
- return invokeApprovalBotApi(APPROVAL_BOT_SEND_PATH, body);
- }
- /**
- * 调用飞书审批 Bot 更新接口(SDK Transport)
- *
- * @param body 请求体(message_id、status 等)
- * @return 调用结果
- */
- private static ApprovalBotResult updateApprovalBotMessageInternal(Map<String, Object> 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<String, Object> 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 自定义模板)
- * <p>
- * 请求体结构:custom_title、custom_content、note、actions;Bot 跳转 url 使用 appurl,pc_url 使用 pcurl
- * <p>
- * 接口文档: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<String, String> texts = new LinkedHashMap<>();
- texts.put("@i18n@1", safeTitle);
- texts.put("@i18n@2", safeContent);
- texts.put("@i18n@3", safeNote);
- texts.put("@i18n@4", "查看详情");
- Map<String, Object> 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<String, Object> 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)
- * <p>
- * 适用于标准待办卡片;后续可通过 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<String, String> texts = new LinkedHashMap<>();
- texts.put("@i18n@1", approvalName);
- texts.put("@i18n@2", summary);
- Map<String, Object> summaryItem = new LinkedHashMap<>();
- summaryItem.put("summary", "@i18n@2");
- Map<String, Object> content = new LinkedHashMap<>();
- content.put("user_id", safeTitleUserId);
- content.put("summaries", Collections.singletonList(summaryItem));
- Map<String, Object> 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<String, Object> 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 消息状态
- * <p>
- * 注意:仅支持更新 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<String, Object> body = new LinkedHashMap<>();
- body.put("message_id", messageId);
- body.put("status", safeStatus);
- if (BOT_STATUS_CUSTOM.equals(safeStatus) && StringUtils.isNotBlank(statusText)) {
- Map<String, String> 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.<String, String>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<String, Object> buildI18nResource(Map<String, String> texts) {
- Map<String, Object> 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);
- }
- }
|