FeishuMessageUtil.java 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197
  1. package com.kingdee.eas.custom.perfweb.utils;
  2. import com.alibaba.fastjson.JSON;
  3. import com.kingdee.bos.BOSException;
  4. import com.kingdee.bos.Context;
  5. import com.kingdee.bos.metadata.entity.*;
  6. import com.kingdee.bos.metadata.query.util.CompareType;
  7. import com.kingdee.shr.perfweb.app.base.evalplan.*;
  8. import com.kingdee.shr.perfweb.app.workflow.GradeTaskNodeInstInfo;
  9. import com.lark.oapi.Client;
  10. import com.lark.oapi.core.Config;
  11. import com.lark.oapi.core.Transport;
  12. import com.lark.oapi.core.request.RequestOptions;
  13. import com.lark.oapi.core.response.RawResponse;
  14. import com.lark.oapi.core.token.AccessTokenType;
  15. import com.lark.oapi.core.utils.Jsons;
  16. import com.lark.oapi.core.utils.Sets;
  17. import com.lark.oapi.service.auth.v3.model.InternalTenantAccessTokenReq;
  18. import com.lark.oapi.service.auth.v3.model.InternalTenantAccessTokenReqBody;
  19. import com.lark.oapi.service.auth.v3.model.InternalTenantAccessTokenResp;
  20. import org.apache.commons.lang3.StringUtils;
  21. import org.apache.log4j.Logger;
  22. import org.springframework.util.CollectionUtils;
  23. import java.io.OutputStream;
  24. import java.net.HttpURLConnection;
  25. import java.net.URL;
  26. import java.nio.charset.StandardCharsets;
  27. import java.text.SimpleDateFormat;
  28. import java.util.*;
  29. /**
  30. * 飞书消息/待办推送工具类
  31. * <p>
  32. * 提供:
  33. * 1. sendInitialFeishuTodos — 启动考核后推送飞书消息(按处理人去重)
  34. * 2. sendAfterScoreSubmit — 评分提交后向下一节点处理人推送飞书消息(按处理人去重)
  35. * 3. sendRemindMessage / sendMessage — 飞书 SDK 自定义 Bot 消息
  36. * 4. sendFeishuCall / sendFeishuTodo — 时创意 OA 飞书待办接口(新建/修改/办结)
  37. * 5. sendAfterScoreSubmitOaTodo — 评分提交后 OA 待办处理(修改/新建/办结)
  38. * 6. sendCustomApprovalBotMessage / sendTodoApprovalBotMessage — 飞书开放平台审批 Bot API
  39. * 7. updateApprovalBotMessage — 飞书开放平台更新审批 Bot 消息状态
  40. *
  41. * @author 青梧
  42. * @date 2026-06-11
  43. */
  44. public class FeishuMessageUtil {
  45. private static Logger logger = Logger.getLogger(FeishuMessageUtil.class);
  46. // ===== 飞书开放平台配置(HR流程)=====
  47. /**
  48. * 正式环境 App ID
  49. */
  50. public static String FEISHU_APP_ID_PROD = "cli_a9c200a401389cbb";
  51. /**
  52. * 正式环境 App Secret
  53. */
  54. public static String FEISHU_APP_SECRET_PROD = "Pho6oFfaFT2CRBB8HyWHnfhigokwtjMD";
  55. /**
  56. * 测试环境 App ID
  57. */
  58. public static String FEISHU_APP_ID_TEST = "cli_a9ca684911b81cc5";
  59. /**
  60. * 测试环境 App Secret
  61. */
  62. public static String FEISHU_APP_SECRET_TEST = "oL2rxjpbKCpwl5vnCxn5PLiDy8A1IR7Q";
  63. /**
  64. * 当前生效 App ID(发版切换 PROD / TEST)
  65. */
  66. public static String FEISHU_APP_ID = FEISHU_APP_ID_PROD;
  67. /**
  68. * 当前生效 App Secret
  69. */
  70. public static String FEISHU_APP_SECRET = FEISHU_APP_SECRET_PROD;
  71. private static final String APPROVAL_BOT_SEND_PATH = "/open-apis/approval/v1/message/send";
  72. private static final String APPROVAL_BOT_UPDATE_PATH = "/open-apis/approval/v1/message/update";
  73. /**
  74. * 通用模板-收到审批待办(支持快捷审批)
  75. */
  76. public static final String TEMPLATE_APPROVAL_TODO = "1008";
  77. /**
  78. * 审批待办模板(approval_name + comment + content.summaries)
  79. */
  80. public static final String TEMPLATE_APPROVAL_COMMENT = "1001";
  81. /**
  82. * 自定义模板(custom_title + custom_content;sendCustomApprovalBotMessage 使用)
  83. */
  84. public static final String TEMPLATE_CUSTOM = "1021";
  85. /**
  86. * 更新消息-已处理
  87. */
  88. public static final String BOT_STATUS_PROCESSED = "PROCESSED";
  89. /**
  90. * 更新消息-已同意
  91. */
  92. public static final String BOT_STATUS_APPROVED = "APPROVED";
  93. /**
  94. * 更新消息-自定义状态
  95. */
  96. public static final String BOT_STATUS_CUSTOM = "CUSTOM";
  97. /**
  98. * 启动考核 / 评分提交等绩效评定类飞书消息卡片标题(不使用流程定义名如「一级考评流程」)
  99. */
  100. public static final String PERF_SCORE_TODO_TITLE = "\u7EE9\u6548\u8BC4\u5B9A\u5F85\u529E\u63D0\u9192";
  101. private static volatile Client feishuClient;
  102. private static volatile Config feishuConfig;
  103. private static volatile String cachedTenantAccessToken;
  104. private static volatile long tokenExpireAtMs;
  105. private static final Object FEISHU_INIT_LOCK = new Object();
  106. private static final Object TOKEN_LOCK = new Object();
  107. /**
  108. * flowId(GradeTaskNodeInst.id) → 飞书 message_id,用于后续更新 Bot 消息
  109. */
  110. private static final Map<String, String> FLOW_MESSAGE_ID_CACHE = new HashMap<>();
  111. // ===== 时创意 OA 待办接口默认参数 =====
  112. private static final String DEFAULT_SYSCODE = "SHR";
  113. private static final String DEFAULT_NODENAME = "sHR";
  114. /**
  115. * 0-待审批(新建待办)
  116. */
  117. public static final String ISREMARK_NEW = "0";
  118. /**
  119. * 2-已办(修改待办状态)
  120. */
  121. public static final String ISREMARK_DONE = "2";
  122. /**
  123. * 4-办结(流程结束)
  124. */
  125. public static final String ISREMARK_COMPLETE = "4";
  126. /**
  127. * 0-未读
  128. */
  129. public static final String VIEWTYPE_UNREAD = "0";
  130. /**
  131. * 1-已读
  132. */
  133. public static final String VIEWTYPE_READ = "1";
  134. /**
  135. * 启动考核后向一级节点处理人发送飞书消息(SDK 自定义 Bot 消息)
  136. * <p>
  137. * 去重逻辑:勾选多个考核对象时,若处理人相同,只发送一条消息
  138. *
  139. * @param ctx 上下文
  140. * @param evaObjHandlerMap key=evaObjId$workFlowId, value=处理人工号集合
  141. * @param evaObjSet 成功的考核对象ID集合
  142. * @param period 考核周期
  143. * @param pcurl PC 端跳转地址
  144. * @param workFlowName 流程名称(如 "绩效考核")
  145. * @param appurl 移动端跳转地址
  146. */
  147. public static void sendInitialFeishuTodos(Context ctx, Map<String, Set<String>> evaObjHandlerMap, Set<String> evaObjSet, PerfPeriodInfo period, String pcurl, String workFlowName, String appurl) {
  148. Set<String> uniqueHandlers = collectUniqueHandlers(evaObjHandlerMap, evaObjSet);
  149. if (uniqueHandlers.isEmpty()) {
  150. logger.error("无处理人信息,跳过飞书消息推送");
  151. return;
  152. }
  153. String title = PERF_SCORE_TODO_TITLE;
  154. String periodName = period != null ? period.getName() : "";
  155. Map<String, String[]> displayMap = loadEvaObjDisplayMap(ctx, evaObjSet);
  156. String periodId = period != null && period.getId() != null ? period.getId().toString() : "";
  157. logger.error("启动考核飞书消息推送参数:periodId=" + periodId + ",workFlowName=" + workFlowName + ",title=" + title + ",handlerCount=" + uniqueHandlers.size() + ",handlers=" + uniqueHandlers + ",pcurl=" + pcurl + ",appurl=" + appurl);
  158. for (String handlerId : uniqueHandlers) {
  159. String content = buildRemindContentForHandler(handlerId, evaObjHandlerMap, evaObjSet, displayMap, periodName);
  160. String uuid = "start_eva_" + periodId + "_" + handlerId + "_" + System.currentTimeMillis();
  161. ApprovalBotResult result = sendCustomApprovalBotMessage(handlerId, uuid, title, content, pcurl, appurl, null);
  162. if (!result.isSuccess()) {
  163. logger.error("启动考核飞书消息发送失败,handler=" + handlerId + ",code=" + result.getCode() + ",msg=" + result.getMsg());
  164. } else {
  165. logger.error("启动考核飞书消息发送成功,handler=" + handlerId + ",messageId=" + result.getMessageId());
  166. }
  167. }
  168. }
  169. /**
  170. * 从 evaObjHandlerMap 中收集去重后的处理人工号
  171. * <p>
  172. * 仅统计 evaObjSet 中启动成功的考核对象
  173. *
  174. * @param evaObjHandlerMap key=evaObjId$workFlowId, value=处理人工号集合
  175. * @param evaObjSet 成功的考核对象ID集合
  176. * @return 去重后的处理人工号集合(保持插入顺序)
  177. */
  178. private static Set<String> collectUniqueHandlers(Map<String, Set<String>> evaObjHandlerMap, Set<String> evaObjSet) {
  179. Set<String> handlers = new LinkedHashSet<>();
  180. if (evaObjHandlerMap == null || CollectionUtils.isEmpty(evaObjSet)) {
  181. return handlers;
  182. }
  183. for (Map.Entry<String, Set<String>> entry : evaObjHandlerMap.entrySet()) {
  184. String evaObjId = parseEvaObjId(entry.getKey());
  185. if (StringUtils.isBlank(evaObjId) || !evaObjSet.contains(evaObjId)) {
  186. continue;
  187. }
  188. if (CollectionUtils.isEmpty(entry.getValue())) {
  189. continue;
  190. }
  191. for (String handlerId : entry.getValue()) {
  192. if (StringUtils.isNotBlank(handlerId)) {
  193. handlers.add(handlerId);
  194. }
  195. }
  196. }
  197. return handlers;
  198. }
  199. /**
  200. * 启动考核后向一级节点处理人发送 OA 飞书待办(按考核对象逐条,flowId=GradeTaskNodeInst.id)
  201. *
  202. * @param ctx 上下文
  203. * @param evaObjHandlerMap key=evaObjId$workFlowId, value=处理人工号集合
  204. * @param evaObjSet 成功的考核对象ID集合
  205. * @param period 考核周期
  206. * @param pcurl PC 端跳转地址
  207. * @param workFlowName 流程名称
  208. * @param appurl 移动端跳转地址
  209. * @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入)
  210. */
  211. public static void sendInitialFeishuTodosOa(Context ctx, Map<String, Set<String>> evaObjHandlerMap, Set<String> evaObjSet, PerfPeriodInfo period, String pcurl, String workFlowName, String appurl, String creatorId) {
  212. if (StringUtils.isBlank(creatorId)) {
  213. logger.error("creatorId 不能为空,跳过 OA 飞书待办推送");
  214. return;
  215. }
  216. if (evaObjHandlerMap == null || evaObjHandlerMap.isEmpty()) {
  217. logger.error("无处理人信息,跳过 OA 飞书待办推送");
  218. return;
  219. }
  220. String requestName = buildScoreRequestName(period);
  221. for (Map.Entry<String, Set<String>> entry : evaObjHandlerMap.entrySet()) {
  222. String evaObjId = parseEvaObjId(entry.getKey());
  223. if (StringUtils.isBlank(evaObjId) || !evaObjSet.contains(evaObjId)) {
  224. continue;
  225. }
  226. String flowId = PerfEvaObjectUtil.getGradeTaskNodeInstId(ctx, evaObjId, 1);
  227. if (StringUtils.isBlank(flowId)) {
  228. logger.error("未找到一级节点实例,跳过 OA 待办,evaObjId=" + evaObjId);
  229. continue;
  230. }
  231. sendToHandlers(entry.getValue(), requestName, pcurl, workFlowName, appurl, flowId, VIEWTYPE_UNREAD, ISREMARK_NEW, creatorId);
  232. }
  233. }
  234. /**
  235. * 评分提交后向下一节点处理人发送飞书消息(SDK 自定义 Bot 消息)
  236. * <p>
  237. * 去重逻辑:批量提交多个考核对象时,若下一节点处理人相同,只发送一条消息
  238. * <p>
  239. * 流程结束时(无下一节点处理人)不推送消息
  240. *
  241. * @param ctx 上下文(保留参数,便于扩展)
  242. * @param evaObjSubmitWfLevelMap key=evaObjId, value=当前提交的节点等级 wfLevel
  243. * @param nextHandlerMap key=evaObjId$workFlowId, value=下一节点处理人工号集合
  244. * @param evaObjSet 提交成功的考核对象ID集合
  245. * @param period 考核周期
  246. * @param pcurl PC 端跳转地址
  247. * @param workFlowName 流程名称
  248. * @param appurl 移动端跳转地址
  249. */
  250. 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) {
  251. if (evaObjSet == null || evaObjSet.isEmpty() || period == null) {
  252. return;
  253. }
  254. Set<String> nextHandlers = collectUniqueNextHandlers(evaObjSet, nextHandlerMap);
  255. if (nextHandlers.isEmpty()) {
  256. logger.error("评分提交后无下一节点处理人,跳过飞书消息推送");
  257. return;
  258. }
  259. String title = PERF_SCORE_TODO_TITLE;
  260. String periodName = period.getName();
  261. Map<String, String[]> displayMap = loadEvaObjDisplayMap(ctx, evaObjSet);
  262. String periodId = period.getId() != null ? period.getId().toString() : "";
  263. logger.error("评分提交飞书消息推送参数:periodId=" + periodId + ",workFlowName=" + workFlowName + ",title=" + title + ",evaObjCount=" + evaObjSet.size() + ",handlerCount=" + nextHandlers.size() + ",handlers=" + nextHandlers + ",pcurl=" + pcurl + ",appurl=" + appurl);
  264. for (String handlerId : nextHandlers) {
  265. String content = buildRemindContentForHandler(handlerId, nextHandlerMap, evaObjSet, displayMap, periodName);
  266. String uuid = "score_next_" + periodId + "_" + handlerId + "_" + System.currentTimeMillis();
  267. ApprovalBotResult result = sendCustomApprovalBotMessage(handlerId, uuid, title, content, pcurl, appurl, null);
  268. if (!result.isSuccess()) {
  269. logger.error("评分提交飞书消息发送失败,handler=" + handlerId + ",code=" + result.getCode() + ",msg=" + result.getMsg());
  270. } else {
  271. logger.error("评分提交飞书消息发送成功,handler=" + handlerId + ",messageId=" + result.getMessageId());
  272. }
  273. }
  274. }
  275. /**
  276. * 收集下一节点去重后的处理人工号
  277. *
  278. * @param evaObjSet 提交成功的考核对象ID集合
  279. * @param nextHandlerMap 下一节点处理人 Map
  280. * @return 去重后的处理人工号集合
  281. */
  282. private static Set<String> collectUniqueNextHandlers(Set<String> evaObjSet, Map<String, Set<String>> nextHandlerMap) {
  283. Set<String> handlers = new LinkedHashSet<>();
  284. if (CollectionUtils.isEmpty(evaObjSet)) {
  285. return handlers;
  286. }
  287. for (String evaObjId : evaObjSet) {
  288. Set<String> objHandlers = findHandlersForEvaObj(evaObjId, nextHandlerMap);
  289. if (!CollectionUtils.isEmpty(objHandlers)) {
  290. for (String handlerId : objHandlers) {
  291. if (StringUtils.isNotBlank(handlerId)) {
  292. handlers.add(handlerId);
  293. }
  294. }
  295. }
  296. }
  297. return handlers;
  298. }
  299. /**
  300. * 从处理人 Map 中查找指定考核对象的下一节点处理人工号
  301. * <p>
  302. * handlerMap 的 key 格式为 evaObjId$workFlowId,按 evaObjId 前缀匹配并合并所有处理人
  303. *
  304. * @param evaObjId 考核对象ID
  305. * @param handlerMap key=evaObjId$workFlowId, value=处理人工号集合
  306. * @return 该考核对象对应的处理人工号集合(去重,可能为空)
  307. */
  308. private static Set<String> findHandlersForEvaObj(String evaObjId, Map<String, Set<String>> handlerMap) {
  309. Set<String> handlers = new LinkedHashSet<>();
  310. if (handlerMap == null || StringUtils.isBlank(evaObjId)) {
  311. return handlers;
  312. }
  313. String prefix = evaObjId + "$";
  314. for (Map.Entry<String, Set<String>> entry : handlerMap.entrySet()) {
  315. if (entry.getKey() != null && entry.getKey().startsWith(prefix) && !CollectionUtils.isEmpty(entry.getValue())) {
  316. handlers.addAll(entry.getValue());
  317. }
  318. }
  319. return handlers;
  320. }
  321. /**
  322. * 批量加载考核对象展示信息:key=evaObjId,value=[部门, 工号, 姓名]
  323. */
  324. private static Map<String, String[]> loadEvaObjDisplayMap(Context ctx, Set<String> evaObjSet) {
  325. Map<String, String[]> result = new HashMap<>();
  326. if (ctx == null || CollectionUtils.isEmpty(evaObjSet)) {
  327. return result;
  328. }
  329. try {
  330. FilterInfo filter = new FilterInfo();
  331. filter.getFilterItems().add(new FilterItemInfo("id", evaObjSet, CompareType.INCLUDE));
  332. EntityViewInfo evi = new EntityViewInfo();
  333. evi.setFilter(filter);
  334. SelectorItemCollection sic = new SelectorItemCollection();
  335. sic.add(new SelectorItemInfo("id"));
  336. sic.add(new SelectorItemInfo("person.number"));
  337. sic.add(new SelectorItemInfo("person.name"));
  338. sic.add(new SelectorItemInfo("adminOrgUnit.name"));
  339. evi.setSelector(sic);
  340. PerfEvaObjectCollection coll = PerfEvaObjectFactory.getLocalInstance(ctx).getPerfEvaObjectCollection(evi);
  341. if (coll == null) {
  342. return result;
  343. }
  344. for (int i = 0; i < coll.size(); i++) {
  345. PerfEvaObjectInfo info = coll.get(i);
  346. if (info == null || info.getId() == null) {
  347. continue;
  348. }
  349. String deptName = info.getAdminOrgUnit() != null ? StringUtils.defaultString(info.getAdminOrgUnit().getName(), "") : "";
  350. String personNumber = "";
  351. String personName = "";
  352. if (info.getPerson() != null) {
  353. personNumber = StringUtils.defaultString(info.getPerson().getNumber(), "");
  354. personName = StringUtils.defaultString(info.getPerson().getName(ctx.getLocale()), "");
  355. }
  356. result.put(info.getId().toString(), new String[]{deptName, personNumber, personName});
  357. }
  358. } catch (BOSException e) {
  359. logger.error("loadEvaObjDisplayMap failed", e);
  360. }
  361. return result;
  362. }
  363. /**
  364. * 按处理人拼装提醒正文:取该处理人名下、勾选顺序中第一条考核对象的信息
  365. */
  366. private static String buildRemindContentForHandler(String handlerId, Map<String, Set<String>> handlerMap, Set<String> evaObjSet, Map<String, String[]> displayMap, String periodName) {
  367. if (StringUtils.isBlank(handlerId) || CollectionUtils.isEmpty(evaObjSet)) {
  368. return buildEvaResultSummaryRemindMessage("", "", "", periodName);
  369. }
  370. for (String evaObjId : evaObjSet) {
  371. Set<String> handlers = findHandlersForEvaObj(evaObjId, handlerMap);
  372. if (CollectionUtils.isEmpty(handlers) || !handlers.contains(handlerId)) {
  373. continue;
  374. }
  375. String[] display = displayMap != null ? displayMap.get(evaObjId) : null;
  376. if (display != null) {
  377. return buildEvaResultSummaryRemindMessage(display[0], display[1], display[2], periodName);
  378. }
  379. return buildEvaResultSummaryRemindMessage("", "", "", periodName);
  380. }
  381. return buildEvaResultSummaryRemindMessage("", "", "", periodName);
  382. }
  383. /**
  384. * 从 evaObjHandlerMap 的 key 中解析考核对象ID
  385. * <p>
  386. * key 格式为 evaObjId$workFlowId,取 $ 前面的部分;无 $ 时返回原 key
  387. *
  388. * @param key evaObjHandlerMap 的 key
  389. * @return 考核对象ID;key 为空时返回 null
  390. */
  391. private static String parseEvaObjId(String key) {
  392. if (StringUtils.isBlank(key)) {
  393. return null;
  394. }
  395. int idx = key.indexOf("$");
  396. return idx > 0 ? key.substring(0, idx) : key;
  397. }
  398. /**
  399. * 发送飞书提醒消息(SDK 自定义 Bot 消息)
  400. *
  401. * @param receiverId 接收人工号(飞书 user_id,与现有 OA 接口一致)
  402. * @param title 消息标题
  403. * @param content 消息正文
  404. * @param pcurl PC 端跳转地址
  405. * @param appurl 移动端跳转地址
  406. * @param uuid 幂等 ID
  407. * @return 调用结果
  408. */
  409. public static ApprovalBotResult sendRemindMessage(String receiverId, String title, String content, String pcurl, String appurl, String uuid) {
  410. if (StringUtils.isBlank(receiverId)) {
  411. ApprovalBotResult result = new ApprovalBotResult();
  412. result.code = -1;
  413. result.msg = "receiverId 不能为空";
  414. return result;
  415. }
  416. String safeTitle = StringUtils.defaultIfBlank(title, "绩效考核提醒");
  417. String safeUuid = StringUtils.isNotBlank(uuid) ? uuid : String.valueOf(System.currentTimeMillis());
  418. logger.error("飞书 sendRemindMessage 参数:receiverId=" + receiverId + ",title=" + safeTitle + ",content=" + content + ",pcurl=" + pcurl + ",appurl=" + appurl + ",uuid=" + safeUuid);
  419. return sendCustomApprovalBotMessage(receiverId, safeUuid, safeTitle, content, pcurl, appurl, null);
  420. }
  421. /**
  422. * 发送飞书消息(兼容旧调用签名)
  423. *
  424. * @param receiverId 接收人工号
  425. * @param content 消息正文
  426. * @param pcurl PC 端跳转地址
  427. * @param workFlowName 消息标题(流程名称)
  428. * @param appurl 移动端跳转地址
  429. * @param uuid 幂等 ID(可传 flowId 或业务唯一键)
  430. * @param receivets 保留参数,兼容旧调用
  431. * @param receivedatetime 保留参数,兼容旧调用
  432. * @return 调用结果
  433. */
  434. public static ApprovalBotResult sendMessage(String receiverId, String content, String pcurl, String workFlowName, String appurl, String uuid, String receivets, String receivedatetime) {
  435. return sendRemindMessage(receiverId, workFlowName, content, pcurl, appurl, uuid);
  436. }
  437. /**
  438. * 拼装启动考核/评分提交飞书消息正文
  439. * <p>
  440. * 消息格式:
  441. * <pre>
  442. * 您(工号:{personNumber},姓名:{personName}){periodDisplay}绩效尚未完成审批评定
  443. * 请点击此消息进入 HR 系统完成绩效等级评定。
  444. * </pre>
  445. * <p>
  446. * 卡片标题(custom_title)使用 {@link #PERF_SCORE_TODO_TITLE},流程名称 workFlowName 仅用于日志排查。
  447. *
  448. * @param deptName 保留参数兼容调用方,消息正文不再展示部门
  449. * @param personNumber 被考核人工号
  450. * @param personName 被考核人姓名
  451. * @param periodDisplay 考核周期展示文本(如 "SCY-2026年02季度")
  452. * @return 完整飞书待办消息正文(含换行)
  453. */
  454. public static String buildEvaResultSummaryRemindMessage(String deptName, String personNumber, String personName, String periodDisplay) {
  455. String number = StringUtils.defaultString(personNumber, "");
  456. String name = StringUtils.defaultString(personName, "");
  457. String period = StringUtils.defaultString(periodDisplay, "");
  458. return "您组织下的(工号:" + number + ",姓名:" + name + ")" + period + "绩效尚未完成审批评定\n" + "请点击此消息进入 HR 系统完成绩效等级评定。";
  459. }
  460. /**
  461. * 绩效结果审批通过通知正文(保留模板,当前无业务调用)
  462. *
  463. * @param periodDisplay 考核周期名称
  464. * @param gradeLevel 无等级时传空,展示为「—」
  465. */
  466. public static String buildEvaResultApprovedNotifyMessage(String periodDisplay, String gradeLevel) {
  467. String period = StringUtils.defaultString(periodDisplay, "");
  468. String grade = StringUtils.isNotBlank(gradeLevel) ? gradeLevel : "—";
  469. return "【绩效结果通知】\n" + "您" + period + "绩效考核已审批通过,等级为 " + grade + "。\n" + "请点击此消息进入 HR 系统查看详情。";
  470. }
  471. /**
  472. * 考核结果汇总列表「发送飞书消息」按钮专用模板(sendFeishuToLeadersAction)
  473. *
  474. * @param periodDisplay 考核周期名称(如「2026 年 一 季度」)
  475. */
  476. public static String buildEvaResultSummaryFeishuMessage(String periodDisplay) {
  477. String period = StringUtils.defaultString(periodDisplay, "");
  478. return "【绩效结果公示通知】您的" + period + "绩效考核结果已出,可前往 HR 系统查看。";
  479. }
  480. // ===== 时创意 OA 飞书待办通道(保留) =====
  481. /**
  482. * 拼装绩效考核 OA 待办标题
  483. *
  484. * @param period 考核周期
  485. * @return 待办标题
  486. */
  487. public static String buildScoreRequestName(PerfPeriodInfo period) {
  488. String periodName = period != null ? period.getName() : "";
  489. return "绩效考核待办:【" + periodName + "】考核周期内存在考核任务待处理,请前往绩效工作台进行评分!!";
  490. }
  491. /**
  492. * 评分提交后 OA 飞书待办处理(修改上一级/新建下一级/办结)
  493. * <p>
  494. * flowId 均取 GradeTaskNodeInst.id
  495. *
  496. * @param ctx 上下文
  497. * @param evaObjSubmitWfLevelMap key=evaObjId, value=当前提交的节点等级 wfLevel
  498. * @param nextHandlerMap key=evaObjId$workFlowId, value=下一节点处理人工号集合
  499. * @param evaObjSet 提交成功的考核对象ID集合
  500. * @param period 考核周期
  501. * @param pcurl PC端跳转地址
  502. * @param workFlowName 流程名称
  503. * @param appurl 移动端跳转地址
  504. * @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入)
  505. */
  506. 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) {
  507. if (StringUtils.isBlank(creatorId)) {
  508. logger.error("creatorId 不能为空,跳过 OA 飞书待办处理");
  509. return;
  510. }
  511. if (evaObjSet == null || evaObjSet.isEmpty() || period == null) {
  512. return;
  513. }
  514. String requestName = buildScoreRequestName(period);
  515. for (String evaObjId : evaObjSet) {
  516. Integer submitWfLevel = evaObjSubmitWfLevelMap != null ? evaObjSubmitWfLevelMap.get(evaObjId) : null;
  517. if (submitWfLevel == null) {
  518. continue;
  519. }
  520. try {
  521. if (submitWfLevel != 1) {
  522. modifyPreviousTodo(ctx, evaObjId, submitWfLevel, requestName, pcurl, workFlowName, appurl, creatorId);
  523. }
  524. Set<String> nextHandlers = findHandlersForEvaObj(evaObjId, nextHandlerMap);
  525. if (!CollectionUtils.isEmpty(nextHandlers)) {
  526. String nextFlowId = PerfEvaObjectUtil.getGradeTaskNodeInstId(ctx, evaObjId, submitWfLevel + 1);
  527. if (StringUtils.isBlank(nextFlowId)) {
  528. logger.error("未找到下一节点实例,跳过 OA 新待办,evaObjId=" + evaObjId + ", wfLevel=" + (submitWfLevel + 1));
  529. continue;
  530. }
  531. sendToHandlers(nextHandlers, requestName, pcurl, workFlowName, appurl, nextFlowId, VIEWTYPE_UNREAD, ISREMARK_NEW, creatorId);
  532. } else {
  533. completeCurrentTodo(ctx, evaObjId, submitWfLevel, requestName, pcurl, workFlowName, appurl, creatorId);
  534. }
  535. } catch (Exception e) {
  536. logger.error("评分提交 OA 待办处理失败,evaObjId=" + evaObjId, e);
  537. }
  538. }
  539. }
  540. /**
  541. * 修改上一级 OA 飞书待办为已办(viewtype=1,isremark=2)
  542. */
  543. private static void modifyPreviousTodo(Context ctx, String evaObjId, int submitWfLevel, String requestName, String pcurl, String workFlowName, String appurl, String creatorId) throws BOSException {
  544. GradeTaskNodeInstInfo prevInst = PerfEvaObjectUtil.getGradeTaskNodeInst(ctx, evaObjId, String.valueOf(submitWfLevel - 1));
  545. if (prevInst == null || prevInst.getId() == null) {
  546. logger.error("未找到上一级节点实例,跳过修改 OA 待办,evaObjId=" + evaObjId + ", wfLevel=" + (submitWfLevel - 1));
  547. return;
  548. }
  549. String receiverId = PerfEvaObjectUtil.getNodeHandlerNumber(prevInst);
  550. if (StringUtils.isBlank(receiverId)) {
  551. logger.error("上一级节点无处理人,跳过修改 OA 待办,evaObjId=" + evaObjId);
  552. return;
  553. }
  554. sendFeishuCall(receiverId, requestName, pcurl, workFlowName, appurl, prevInst.getId().toString(), VIEWTYPE_READ, ISREMARK_DONE, creatorId);
  555. }
  556. /**
  557. * 流程结束时办结当前节点 OA 飞书待办(viewtype=1,isremark=4)
  558. */
  559. private static void completeCurrentTodo(Context ctx, String evaObjId, int submitWfLevel, String requestName, String pcurl, String workFlowName, String appurl, String creatorId) throws BOSException {
  560. GradeTaskNodeInstInfo currentInst = PerfEvaObjectUtil.getGradeTaskNodeInst(ctx, evaObjId, String.valueOf(submitWfLevel));
  561. if (currentInst == null || currentInst.getId() == null) {
  562. logger.error("未找到当前节点实例,跳过 OA 办结,evaObjId=" + evaObjId + ", wfLevel=" + submitWfLevel);
  563. return;
  564. }
  565. String receiverId = PerfEvaObjectUtil.getNodeHandlerNumber(currentInst);
  566. if (StringUtils.isBlank(receiverId)) {
  567. logger.error("当前节点无处理人,跳过 OA 办结,evaObjId=" + evaObjId);
  568. return;
  569. }
  570. sendFeishuCall(receiverId, requestName, pcurl, workFlowName, appurl, currentInst.getId().toString(), VIEWTYPE_READ, ISREMARK_COMPLETE, creatorId);
  571. }
  572. /**
  573. * 向多个处理人批量发送 OA 飞书待办/修改/办结
  574. */
  575. private static void sendToHandlers(Set<String> handlers, String requestName, String pcurl, String workFlowName, String appurl, String flowId, String viewtype, String isremark, String creatorId) {
  576. if (CollectionUtils.isEmpty(handlers) || StringUtils.isBlank(flowId)) {
  577. return;
  578. }
  579. for (String handlerId : handlers) {
  580. if (StringUtils.isNotBlank(handlerId)) {
  581. sendFeishuCall(handlerId, requestName, pcurl, workFlowName, appurl, flowId, viewtype, isremark, creatorId);
  582. }
  583. }
  584. }
  585. /**
  586. * 发送 OA 飞书待办(新建,viewtype=0,isremark=0)
  587. *
  588. * @param receiverId 接收人工号
  589. * @param requestName 待办标题
  590. * @param pcurl PC 端跳转地址
  591. * @param workFlowName 流程名称
  592. * @param appurl 移动端跳转地址
  593. * @param flowId 流程 ID(GradeTaskNodeInst.id)
  594. * @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入)
  595. */
  596. public static void sendFeishuTodo(String receiverId, String requestName, String pcurl, String workFlowName, String appurl, String flowId, String creatorId) {
  597. sendFeishuCall(receiverId, requestName, pcurl, workFlowName, appurl, flowId, VIEWTYPE_UNREAD, ISREMARK_NEW, creatorId);
  598. }
  599. /**
  600. * 发送 OA 飞书待办/修改/办结(可指定 viewtype、isremark)
  601. * <p>
  602. * - 新建待办:viewtype=0, isremark=0
  603. * - 修改待办:viewtype=1, isremark=2
  604. * - 流程办结:viewtype=1, isremark=4
  605. *
  606. * @param receiverId 接收人工号
  607. * @param requestName 待办标题
  608. * @param pcurl PC 端跳转地址
  609. * @param workFlowName 流程名称
  610. * @param appurl 移动端跳转地址
  611. * @param flowId 流程 ID(GradeTaskNodeInst.id)
  612. * @param viewtype 查看状态(0 未读/1 已读)
  613. * @param isremark 待办状态(0 待审批/2 已办/4 办结)
  614. * @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入)
  615. */
  616. public static void sendFeishuCall(String receiverId, String requestName, String pcurl, String workFlowName, String appurl, String flowId, String viewtype, String isremark, String creatorId) {
  617. if (StringUtils.isBlank(creatorId)) {
  618. logger.error("OA 飞书待办 creatorId 不能为空,receiverId=" + receiverId + ", flowId=" + flowId);
  619. return;
  620. }
  621. if (StringUtils.isBlank(receiverId) || StringUtils.isBlank(flowId)) {
  622. logger.error("OA 飞书待办参数不完整,receiverId=" + receiverId + ", flowId=" + flowId);
  623. return;
  624. }
  625. String receivets = String.valueOf(System.currentTimeMillis());
  626. String nowStr = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
  627. String apiUrl = UrlParamUtil.PRODURL;
  628. String safePcurl = StringUtils.defaultString(pcurl);
  629. String safeAppurl = StringUtils.isNotBlank(appurl) ? appurl : safePcurl;
  630. String jsonBody = buildOaTodoRequestJson(receiverId, requestName, safePcurl, workFlowName, safeAppurl, flowId, receivets, nowStr, viewtype, isremark, creatorId);
  631. logger.error("OA 飞书待办请求 URL=" + apiUrl);
  632. logger.error("OA 飞书待办请求 Body=" + jsonBody);
  633. HttpURLConnection conn = null;
  634. try {
  635. URL url = new URL(apiUrl);
  636. conn = (HttpURLConnection) url.openConnection();
  637. conn.setRequestMethod("POST");
  638. conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
  639. conn.setDoOutput(true);
  640. conn.setConnectTimeout(10000);
  641. conn.setReadTimeout(10000);
  642. OutputStream os = conn.getOutputStream();
  643. os.write(jsonBody.getBytes("UTF-8"));
  644. os.flush();
  645. os.close();
  646. int responseCode = conn.getResponseCode();
  647. logger.error("OA 飞书待办响应码=" + responseCode + ",receiverId=" + receiverId + ",flowId=" + flowId + ",viewtype=" + viewtype + ",isremark=" + isremark);
  648. } catch (Exception e) {
  649. logger.error("OA 飞书待办推送异常,receiverId=" + receiverId + ",flowId=" + flowId, e);
  650. } finally {
  651. if (conn != null) {
  652. conn.disconnect();
  653. }
  654. }
  655. }
  656. /**
  657. * 构建 OA 飞书待办请求 JSON
  658. */
  659. 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) {
  660. 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) + "\"}";
  661. }
  662. /**
  663. * JSON 字符串转义
  664. */
  665. private static String escapeJson(String str) {
  666. if (str == null) {
  667. return "";
  668. }
  669. return str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
  670. }
  671. // ===== 飞书开放平台官方 API(SDK Transport)=====
  672. /**
  673. * 飞书审批 Bot 消息调用结果
  674. */
  675. public static class ApprovalBotResult {
  676. private int code;
  677. private String msg;
  678. private String messageId;
  679. public int getCode() {
  680. return code;
  681. }
  682. public String getMsg() {
  683. return msg;
  684. }
  685. public String getMessageId() {
  686. return messageId;
  687. }
  688. public boolean isSuccess() {
  689. return code == 0;
  690. }
  691. }
  692. /**
  693. * 获取飞书 SDK Config(单例)
  694. *
  695. * @return Config 实例;AppId/AppSecret 未配置时返回 null
  696. */
  697. private static Config getFeishuConfig() {
  698. if (feishuConfig != null) {
  699. return feishuConfig;
  700. }
  701. synchronized (FEISHU_INIT_LOCK) {
  702. if (feishuConfig != null) {
  703. return feishuConfig;
  704. }
  705. if (StringUtils.isBlank(FEISHU_APP_ID) || StringUtils.isBlank(FEISHU_APP_SECRET)) {
  706. logger.error("飞书 AppId/AppSecret 未配置,无法创建 SDK Config");
  707. return null;
  708. }
  709. Config config = new Config();
  710. config.setAppId(FEISHU_APP_ID);
  711. config.setAppSecret(FEISHU_APP_SECRET);
  712. feishuConfig = config;
  713. if (feishuClient == null) {
  714. feishuClient = Client.newBuilder(FEISHU_APP_ID, FEISHU_APP_SECRET).build();
  715. }
  716. return feishuConfig;
  717. }
  718. }
  719. /**
  720. * 获取飞书 SDK Client(单例)
  721. *
  722. * @return Client 实例;AppId/AppSecret 未配置时返回 null
  723. */
  724. private static Client getFeishuClient() {
  725. if (feishuClient != null) {
  726. return feishuClient;
  727. }
  728. getFeishuConfig();
  729. return feishuClient;
  730. }
  731. /**
  732. * 构建 SDK 请求选项(自动附带 tenant_access_token)
  733. *
  734. * @return RequestOptions
  735. */
  736. private static RequestOptions buildRequestOptions() {
  737. String token = getTenantAccessToken();
  738. RequestOptions.Builder builder = RequestOptions.newBuilder();
  739. if (StringUtils.isNotBlank(token)) {
  740. builder.tenantAccessToken(token);
  741. }
  742. return builder.build();
  743. }
  744. /**
  745. * 获取 tenant_access_token(飞书 SDK + 内存缓存,过期前 60 秒自动刷新)
  746. * <p>
  747. * 接口文档:https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal
  748. *
  749. * @return tenant_access_token;失败返回 null
  750. */
  751. public static String getTenantAccessToken() {
  752. long now = System.currentTimeMillis();
  753. if (StringUtils.isNotBlank(cachedTenantAccessToken) && now < tokenExpireAtMs - 60000) {
  754. return cachedTenantAccessToken;
  755. }
  756. synchronized (TOKEN_LOCK) {
  757. now = System.currentTimeMillis();
  758. if (StringUtils.isNotBlank(cachedTenantAccessToken) && now < tokenExpireAtMs - 60000) {
  759. return cachedTenantAccessToken;
  760. }
  761. Client client = getFeishuClient();
  762. if (client == null) {
  763. return null;
  764. }
  765. try {
  766. InternalTenantAccessTokenReq req = InternalTenantAccessTokenReq.newBuilder().internalTenantAccessTokenReqBody(InternalTenantAccessTokenReqBody.newBuilder().appId(FEISHU_APP_ID).appSecret(FEISHU_APP_SECRET).build()).build();
  767. InternalTenantAccessTokenResp resp = client.auth().v3().tenantAccessToken().internal(req);
  768. if (!resp.success()) {
  769. logger.error("获取 tenant_access_token 失败,code=" + resp.getCode() + ",msg=" + resp.getMsg() + ",reqId=" + resp.getRequestId());
  770. return null;
  771. }
  772. String response = new String(resp.getRawResponse().getBody(), StandardCharsets.UTF_8);
  773. String token = extractJsonString(response, "tenant_access_token");
  774. if (StringUtils.isBlank(token)) {
  775. logger.error("获取 tenant_access_token 失败,响应中无 token,response=" + response);
  776. return null;
  777. }
  778. int expire = extractJsonInt(response, "expire", 7200);
  779. cachedTenantAccessToken = token;
  780. tokenExpireAtMs = System.currentTimeMillis() + expire * 1000L;
  781. return token;
  782. } catch (Exception e) {
  783. logger.error("飞书 SDK 获取 tenant_access_token 异常", e);
  784. return null;
  785. }
  786. }
  787. }
  788. /**
  789. * 调用飞书审批 Bot 发送接口(SDK Transport)
  790. *
  791. * @param body 请求体(template_id、user_id 等)
  792. * @return 调用结果
  793. */
  794. private static ApprovalBotResult sendApprovalBotMessage(Map<String, Object> body) {
  795. return invokeApprovalBotApi(APPROVAL_BOT_SEND_PATH, body);
  796. }
  797. /**
  798. * 调用飞书审批 Bot 更新接口(SDK Transport)
  799. *
  800. * @param body 请求体(message_id、status 等)
  801. * @return 调用结果
  802. */
  803. private static ApprovalBotResult updateApprovalBotMessageInternal(Map<String, Object> body) {
  804. return invokeApprovalBotApi(APPROVAL_BOT_UPDATE_PATH, body);
  805. }
  806. /**
  807. * 通过 SDK Transport 调用审批 Bot OpenAPI
  808. *
  809. * @param path 接口路径
  810. * @param body 请求 JSON 对象
  811. * @return 调用结果
  812. */
  813. private static ApprovalBotResult invokeApprovalBotApi(String path, Map<String, Object> body) {
  814. ApprovalBotResult result = new ApprovalBotResult();
  815. Config config = getFeishuConfig();
  816. if (config == null) {
  817. result.code = -1;
  818. result.msg = "飞书 AppId/AppSecret 未配置";
  819. return result;
  820. }
  821. try {
  822. logger.error("飞书 SDK 请求 path=" + path + ",requestBody=" + Jsons.DEFAULT.toJson(body));
  823. RequestOptions reqOpt = buildRequestOptions();
  824. RawResponse raw = Transport.send(config, reqOpt, "POST", path, Sets.newHashSet(AccessTokenType.Tenant), body);
  825. return parseApprovalBotRawResponse(raw, path);
  826. } catch (Exception e) {
  827. logger.error("飞书 SDK 调用审批 Bot 接口异常,path=" + path, e);
  828. result.code = -1;
  829. result.msg = e.getMessage();
  830. return result;
  831. }
  832. }
  833. /**
  834. * 发送审批 Bot 消息(template_id=1021 自定义模板)
  835. * <p>
  836. * 请求体结构:custom_title、custom_content、note、actions;Bot 跳转 url 使用 appurl,pc_url 使用 pcurl
  837. * <p>
  838. * 接口文档:https://open.feishu.cn/document/server-docs/approval-v4/message/send-bot-messages
  839. *
  840. * @param userId 接收人 user_id(当前与工号一致)
  841. * @param uuid 幂等 ID,相同 uuid 1 小时内只发一次
  842. * @param title 卡片标题 → custom_title
  843. * @param content 卡片正文 → custom_content(支持 Markdown)
  844. * @param pcUrl PC 端跳转地址
  845. * @param mobileUrl 移动端跳转地址
  846. * @param note 底部备注;为空时默认「来自 HR 绩效考核系统」
  847. * @return 调用结果,成功时 messageId 可用于 updateApprovalBotMessage
  848. */
  849. public static ApprovalBotResult sendCustomApprovalBotMessage(String userId, String uuid, String title, String content, String pcUrl, String mobileUrl, String note) {
  850. ApprovalBotResult result = new ApprovalBotResult();
  851. if (StringUtils.isBlank(userId)) {
  852. result.code = -1;
  853. result.msg = "userId 不能为空";
  854. return result;
  855. }
  856. String safeUuid = StringUtils.isNotBlank(uuid) ? uuid : String.valueOf(System.currentTimeMillis());
  857. String safeNote = StringUtils.defaultString(note, "来自 HR 绩效考核系统");
  858. String safeMobileUrl = StringUtils.isNotBlank(mobileUrl) ? mobileUrl : pcUrl;
  859. String safePcUrl = StringUtils.isNotBlank(pcUrl) ? pcUrl : safeMobileUrl;
  860. String safeTitle = StringUtils.defaultIfBlank(title, "绩效考核提醒");
  861. String safeContent = StringUtils.defaultIfBlank(content, safeTitle);
  862. Map<String, String> texts = new LinkedHashMap<>();
  863. texts.put("@i18n@1", safeTitle);
  864. texts.put("@i18n@2", safeContent);
  865. texts.put("@i18n@3", safeNote);
  866. texts.put("@i18n@4", "查看详情");
  867. Map<String, Object> action = new LinkedHashMap<>();
  868. action.put("action_name", "@i18n@4");
  869. action.put("url", safeMobileUrl);
  870. action.put("android_url", safeMobileUrl);
  871. action.put("ios_url", safeMobileUrl);
  872. action.put("pc_url", safePcUrl);
  873. Map<String, Object> body = new LinkedHashMap<>();
  874. body.put("template_id", TEMPLATE_CUSTOM);
  875. body.put("user_id", userId);
  876. body.put("uuid", safeUuid);
  877. body.put("custom_title", "@i18n@1");
  878. body.put("custom_content", "@i18n@2");
  879. body.put("note", "@i18n@3");
  880. body.put("actions", Collections.singletonList(action));
  881. body.put("i18n_resources", Collections.singletonList(buildI18nResource(texts)));
  882. logger.error("飞书 sendCustomApprovalBotMessage 参数:" + JSON.toJSON(body).toString());
  883. System.out.println("飞书 sendCustomApprovalBotMessage 参数:" + JSON.toJSON(body).toString());
  884. return sendApprovalBotMessage(body);
  885. }
  886. /**
  887. * 发送「收到审批待办」Bot 消息(template_id=1008)
  888. * <p>
  889. * 适用于标准待办卡片;后续可通过 updateApprovalBotMessage 更新为已办/办结
  890. *
  891. * @param userId 接收人飞书 user_id
  892. * @param uuid 幂等 ID
  893. * @param approvalName 审批名称(如「绩效考核待办」)
  894. * @param titleUserId 标题中的申请人/被考核人 user_id
  895. * @param summary 审批事由摘要
  896. * @param pcUrl PC 端跳转地址
  897. * @param mobileUrl 移动端跳转地址
  898. * @return 调用结果
  899. */
  900. public static ApprovalBotResult sendTodoApprovalBotMessage(String userId, String uuid, String approvalName, String titleUserId, String summary, String pcUrl, String mobileUrl) {
  901. ApprovalBotResult result = new ApprovalBotResult();
  902. if (StringUtils.isBlank(userId)) {
  903. result.code = -1;
  904. result.msg = "userId 不能为空";
  905. return result;
  906. }
  907. String safeUuid = StringUtils.isNotBlank(uuid) ? uuid : String.valueOf(System.currentTimeMillis());
  908. String safeMobileUrl = StringUtils.isNotBlank(mobileUrl) ? mobileUrl : pcUrl;
  909. String safePcUrl = StringUtils.isNotBlank(pcUrl) ? pcUrl : safeMobileUrl;
  910. String safeTitleUserId = StringUtils.defaultIfBlank(titleUserId, userId);
  911. Map<String, String> texts = new LinkedHashMap<>();
  912. texts.put("@i18n@1", approvalName);
  913. texts.put("@i18n@2", summary);
  914. Map<String, Object> summaryItem = new LinkedHashMap<>();
  915. summaryItem.put("summary", "@i18n@2");
  916. Map<String, Object> content = new LinkedHashMap<>();
  917. content.put("user_id", safeTitleUserId);
  918. content.put("summaries", Collections.singletonList(summaryItem));
  919. Map<String, Object> action = new LinkedHashMap<>();
  920. action.put("action_name", "DETAIL");
  921. action.put("url", safeMobileUrl);
  922. action.put("android_url", safeMobileUrl);
  923. action.put("ios_url", safeMobileUrl);
  924. action.put("pc_url", safePcUrl);
  925. Map<String, Object> body = new LinkedHashMap<>();
  926. body.put("template_id", TEMPLATE_APPROVAL_TODO);
  927. body.put("user_id", userId);
  928. body.put("uuid", safeUuid);
  929. body.put("approval_name", "@i18n@1");
  930. body.put("title_user_id", safeTitleUserId);
  931. body.put("content", content);
  932. body.put("actions", Collections.singletonList(action));
  933. body.put("i18n_resources", Collections.singletonList(buildI18nResource(texts)));
  934. logger.error("飞书 sendTodoApprovalBotMessage:" + body);
  935. return sendApprovalBotMessage(body);
  936. }
  937. /**
  938. * 更新审批 Bot 消息状态
  939. * <p>
  940. * 注意:仅支持更新 template_id=1008 的消息,且 30 天以内
  941. *
  942. * @param messageId 发送接口返回的 message_id
  943. * @param status 状态:PROCESSED/APPROVED/REJECTED/CUSTOM 等
  944. * @param statusText 自定义状态文案(status=CUSTOM 时生效)
  945. * @return 调用结果
  946. */
  947. public static ApprovalBotResult updateApprovalBotMessage(String messageId, String status, String statusText) {
  948. ApprovalBotResult result = new ApprovalBotResult();
  949. if (StringUtils.isBlank(messageId)) {
  950. result.code = -1;
  951. result.msg = "messageId 不能为空";
  952. return result;
  953. }
  954. String safeStatus = StringUtils.defaultIfBlank(status, BOT_STATUS_PROCESSED);
  955. Map<String, Object> body = new LinkedHashMap<>();
  956. body.put("message_id", messageId);
  957. body.put("status", safeStatus);
  958. if (BOT_STATUS_CUSTOM.equals(safeStatus) && StringUtils.isNotBlank(statusText)) {
  959. Map<String, String> texts = new LinkedHashMap<>();
  960. texts.put("@i18n@1", statusText);
  961. texts.put("@i18n@2", "查看详情");
  962. body.put("status_name", "@i18n@1");
  963. body.put("detail_action_name", "@i18n@2");
  964. body.put("i18n_resources", Collections.singletonList(buildI18nResource(texts)));
  965. } else {
  966. body.put("i18n_resources", Collections.singletonList(buildI18nResource(Collections.<String, String>emptyMap())));
  967. }
  968. logger.error("飞书 updateApprovalBotMessage 参数:messageId=" + messageId + ",status=" + safeStatus + ",statusText=" + statusText);
  969. return updateApprovalBotMessageInternal(body);
  970. }
  971. /**
  972. * 缓存 flowId 与飞书 message_id 的映射,便于后续按 GradeTaskNodeInst.id 更新消息
  973. *
  974. * @param flowId GradeTaskNodeInst.id
  975. * @param messageId 飞书 message_id
  976. */
  977. public static void cacheFlowMessageId(String flowId, String messageId) {
  978. if (StringUtils.isNotBlank(flowId) && StringUtils.isNotBlank(messageId)) {
  979. synchronized (FLOW_MESSAGE_ID_CACHE) {
  980. FLOW_MESSAGE_ID_CACHE.put(flowId, messageId);
  981. }
  982. }
  983. }
  984. /**
  985. * 根据 flowId 获取已缓存的飞书 message_id
  986. *
  987. * @param flowId GradeTaskNodeInst.id
  988. * @return message_id;未缓存时返回 null
  989. */
  990. public static String getCachedMessageIdByFlowId(String flowId) {
  991. if (StringUtils.isBlank(flowId)) {
  992. return null;
  993. }
  994. synchronized (FLOW_MESSAGE_ID_CACHE) {
  995. return FLOW_MESSAGE_ID_CACHE.get(flowId);
  996. }
  997. }
  998. /**
  999. * 发送自定义 Bot 消息并缓存 flowId → message_id
  1000. *
  1001. * @param pcUrl PC 跳转地址
  1002. * @param mobileUrl 移动端跳转地址
  1003. * @return 调用结果
  1004. */
  1005. public static ApprovalBotResult sendCustomBotAndCache(String flowId, String userId, String title, String content, String pcUrl, String mobileUrl) {
  1006. ApprovalBotResult result = sendCustomApprovalBotMessage(userId, flowId, title, content, pcUrl, mobileUrl, null);
  1007. if (result.isSuccess() && StringUtils.isNotBlank(flowId)) {
  1008. cacheFlowMessageId(flowId, result.getMessageId());
  1009. }
  1010. return result;
  1011. }
  1012. /**
  1013. * 按 flowId 更新已缓存 Bot 消息为已处理
  1014. *
  1015. * @param flowId GradeTaskNodeInst.id
  1016. * @param statusText 自定义状态文案(可为空,默认「已处理」)
  1017. * @return 调用结果;未找到缓存 message_id 时 code=-1
  1018. */
  1019. public static ApprovalBotResult updateBotByFlowId(String flowId, String statusText) {
  1020. String messageId = getCachedMessageIdByFlowId(flowId);
  1021. if (StringUtils.isBlank(messageId)) {
  1022. ApprovalBotResult result = new ApprovalBotResult();
  1023. result.code = -1;
  1024. result.msg = "未找到 flowId 对应的 message_id,flowId=" + flowId;
  1025. return result;
  1026. }
  1027. if (StringUtils.isNotBlank(statusText)) {
  1028. return updateApprovalBotMessage(messageId, BOT_STATUS_CUSTOM, statusText);
  1029. }
  1030. return updateApprovalBotMessage(messageId, BOT_STATUS_PROCESSED, null);
  1031. }
  1032. /**
  1033. * 构建 i18n_resources 单条资源
  1034. *
  1035. * @param texts i18n 文案键值对
  1036. * @return i18n 资源 Map
  1037. */
  1038. private static Map<String, Object> buildI18nResource(Map<String, String> texts) {
  1039. Map<String, Object> resource = new LinkedHashMap<>();
  1040. resource.put("locale", "zh-CN");
  1041. resource.put("is_default", true);
  1042. resource.put("texts", texts);
  1043. return resource;
  1044. }
  1045. /**
  1046. * 解析 SDK RawResponse 为 ApprovalBotResult
  1047. *
  1048. * @param raw SDK 原始响应
  1049. * @param path 接口路径(日志用)
  1050. * @return 调用结果
  1051. */
  1052. private static ApprovalBotResult parseApprovalBotRawResponse(RawResponse raw, String path) {
  1053. ApprovalBotResult result = new ApprovalBotResult();
  1054. if (raw == null || raw.getBody() == null) {
  1055. result.code = -1;
  1056. result.msg = "飞书 SDK 无响应,path=" + path;
  1057. return result;
  1058. }
  1059. String response = new String(raw.getBody(), StandardCharsets.UTF_8);
  1060. logger.error("飞书 SDK path=" + path + ",status=" + raw.getStatusCode() + ",body=" + response);
  1061. result.code = extractJsonInt(response, "code", -1);
  1062. result.msg = extractJsonString(response, "msg");
  1063. String dataMessageId = extractNestedJsonString(response, "data", "message_id");
  1064. if (StringUtils.isBlank(dataMessageId)) {
  1065. dataMessageId = extractJsonString(response, "message_id");
  1066. }
  1067. result.messageId = dataMessageId;
  1068. return result;
  1069. }
  1070. private static String extractJsonString(String json, String field) {
  1071. if (StringUtils.isBlank(json) || StringUtils.isBlank(field)) {
  1072. return null;
  1073. }
  1074. String key = "\"" + field + "\":\"";
  1075. int start = json.indexOf(key);
  1076. if (start < 0) {
  1077. return null;
  1078. }
  1079. start += key.length();
  1080. int end = json.indexOf("\"", start);
  1081. if (end < 0) {
  1082. return null;
  1083. }
  1084. return json.substring(start, end);
  1085. }
  1086. private static int extractJsonInt(String json, String field, int defaultValue) {
  1087. if (StringUtils.isBlank(json) || StringUtils.isBlank(field)) {
  1088. return defaultValue;
  1089. }
  1090. String key = "\"" + field + "\":";
  1091. int start = json.indexOf(key);
  1092. if (start < 0) {
  1093. return defaultValue;
  1094. }
  1095. start += key.length();
  1096. int end = start;
  1097. while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) {
  1098. end++;
  1099. }
  1100. if (end <= start) {
  1101. return defaultValue;
  1102. }
  1103. try {
  1104. return Integer.parseInt(json.substring(start, end));
  1105. } catch (NumberFormatException e) {
  1106. return defaultValue;
  1107. }
  1108. }
  1109. private static String extractNestedJsonString(String json, String objectField, String field) {
  1110. if (StringUtils.isBlank(json)) {
  1111. return null;
  1112. }
  1113. String objectKey = "\"" + objectField + "\":";
  1114. int objectStart = json.indexOf(objectKey);
  1115. if (objectStart < 0) {
  1116. return null;
  1117. }
  1118. objectStart = json.indexOf("{", objectStart);
  1119. if (objectStart < 0) {
  1120. return null;
  1121. }
  1122. int objectEnd = json.indexOf("}", objectStart);
  1123. if (objectEnd < 0) {
  1124. return null;
  1125. }
  1126. return extractJsonString(json.substring(objectStart, objectEnd + 1), field);
  1127. }
  1128. }