package com.kingdee.eas.custom.perfweb.utils; import com.alibaba.fastjson.JSON; import com.kingdee.bos.BOSException; import com.kingdee.bos.Context; import com.kingdee.bos.metadata.entity.*; import com.kingdee.bos.metadata.query.util.CompareType; import com.kingdee.shr.perfweb.app.base.evalplan.*; import com.kingdee.shr.perfweb.app.workflow.GradeTaskNodeInstInfo; import com.lark.oapi.Client; import com.lark.oapi.core.Config; import com.lark.oapi.core.Transport; import com.lark.oapi.core.request.RequestOptions; import com.lark.oapi.core.response.RawResponse; import com.lark.oapi.core.token.AccessTokenType; import com.lark.oapi.core.utils.Jsons; import com.lark.oapi.core.utils.Sets; import com.lark.oapi.service.auth.v3.model.InternalTenantAccessTokenReq; import com.lark.oapi.service.auth.v3.model.InternalTenantAccessTokenReqBody; import com.lark.oapi.service.auth.v3.model.InternalTenantAccessTokenResp; import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; import org.springframework.util.CollectionUtils; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.*; /** * 飞书消息/待办推送工具类 *
* 提供:
* 1. sendInitialFeishuTodos — 启动考核后推送飞书消息(按处理人去重)
* 2. sendAfterScoreSubmit — 评分提交后向下一节点处理人推送飞书消息(按处理人去重)
* 3. sendRemindMessage / sendMessage — 飞书 SDK 自定义 Bot 消息
* 4. sendFeishuCall / sendFeishuTodo — 时创意 OA 飞书待办接口(新建/修改/办结)
* 5. sendAfterScoreSubmitOaTodo — 评分提交后 OA 待办处理(修改/新建/办结)
* 6. sendCustomApprovalBotMessage / sendTodoApprovalBotMessage — 飞书开放平台审批 Bot API
* 7. updateApprovalBotMessage — 飞书开放平台更新审批 Bot 消息状态
*
* @author 青梧
* @date 2026-06-11
*/
public class FeishuMessageUtil {
private static Logger logger = Logger.getLogger(FeishuMessageUtil.class);
// ===== 飞书开放平台配置(HR流程)=====
/**
* 正式环境 App ID
*/
public static String FEISHU_APP_ID_PROD = "cli_a9c200a401389cbb";
/**
* 正式环境 App Secret
*/
public static String FEISHU_APP_SECRET_PROD = "Pho6oFfaFT2CRBB8HyWHnfhigokwtjMD";
/**
* 测试环境 App ID
*/
public static String FEISHU_APP_ID_TEST = "cli_a9ca684911b81cc5";
/**
* 测试环境 App Secret
*/
public static String FEISHU_APP_SECRET_TEST = "oL2rxjpbKCpwl5vnCxn5PLiDy8A1IR7Q";
/**
* 当前生效 App ID(发版切换 PROD / TEST)
*/
public static String FEISHU_APP_ID = FEISHU_APP_ID_PROD;
/**
* 当前生效 App Secret
*/
public static String FEISHU_APP_SECRET = FEISHU_APP_SECRET_PROD;
private static final String APPROVAL_BOT_SEND_PATH = "/open-apis/approval/v1/message/send";
private static final String APPROVAL_BOT_UPDATE_PATH = "/open-apis/approval/v1/message/update";
/**
* 通用模板-收到审批待办(支持快捷审批)
*/
public static final String TEMPLATE_APPROVAL_TODO = "1008";
/**
* 审批待办模板(approval_name + comment + content.summaries)
*/
public static final String TEMPLATE_APPROVAL_COMMENT = "1001";
/**
* 自定义模板(custom_title + custom_content;sendCustomApprovalBotMessage 使用)
*/
public static final String TEMPLATE_CUSTOM = "1021";
/**
* 更新消息-已处理
*/
public static final String BOT_STATUS_PROCESSED = "PROCESSED";
/**
* 更新消息-已同意
*/
public static final String BOT_STATUS_APPROVED = "APPROVED";
/**
* 更新消息-自定义状态
*/
public static final String BOT_STATUS_CUSTOM = "CUSTOM";
/**
* 启动考核 / 评分提交等绩效评定类飞书消息卡片标题(不使用流程定义名如「一级考评流程」)
*/
public static final String PERF_SCORE_TODO_TITLE = "\u7EE9\u6548\u8BC4\u5B9A\u5F85\u529E\u63D0\u9192";
private static volatile Client feishuClient;
private static volatile Config feishuConfig;
private static volatile String cachedTenantAccessToken;
private static volatile long tokenExpireAtMs;
private static final Object FEISHU_INIT_LOCK = new Object();
private static final Object TOKEN_LOCK = new Object();
/**
* flowId(GradeTaskNodeInst.id) → 飞书 message_id,用于后续更新 Bot 消息
*/
private static final Map
* 去重逻辑:勾选多个考核对象时,若处理人相同,只发送一条消息
*
* @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
* 仅统计 evaObjSet 中启动成功的考核对象
*
* @param evaObjHandlerMap key=evaObjId$workFlowId, value=处理人工号集合
* @param evaObjSet 成功的考核对象ID集合
* @return 去重后的处理人工号集合(保持插入顺序)
*/
private static Set
* 去重逻辑:批量提交多个考核对象时,若下一节点处理人相同,只发送一条消息
*
* 流程结束时(无下一节点处理人)不推送消息
*
* @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
* handlerMap 的 key 格式为 evaObjId$workFlowId,按 evaObjId 前缀匹配并合并所有处理人
*
* @param evaObjId 考核对象ID
* @param handlerMap key=evaObjId$workFlowId, value=处理人工号集合
* @return 该考核对象对应的处理人工号集合(去重,可能为空)
*/
private static Set
* 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);
}
/**
* 拼装启动考核/评分提交飞书消息正文
*
* 消息格式:
*
* 卡片标题(custom_title)使用 {@link #PERF_SCORE_TODO_TITLE},流程名称 workFlowName 仅用于日志排查。
*
* @param deptName 保留参数兼容调用方,消息正文不再展示部门
* @param personNumber 被考核人工号
* @param personName 被考核人姓名
* @param periodDisplay 考核周期展示文本(如 "SCY-2026年02季度")
* @return 完整飞书待办消息正文(含换行)
*/
public static String buildEvaResultSummaryRemindMessage(String deptName, String personNumber, String personName, String periodDisplay) {
String number = StringUtils.defaultString(personNumber, "");
String name = StringUtils.defaultString(personName, "");
String period = StringUtils.defaultString(periodDisplay, "");
return "您组织下的(工号:" + number + ",姓名:" + name + ")" + period + "绩效尚未完成审批评定\n" + "请点击此消息进入 HR 系统完成绩效等级评定。";
}
/**
* 绩效结果审批通过通知正文(保留模板,当前无业务调用)
*
* @param periodDisplay 考核周期名称
* @param gradeLevel 无等级时传空,展示为「—」
*/
public static String buildEvaResultApprovedNotifyMessage(String periodDisplay, String gradeLevel) {
String period = StringUtils.defaultString(periodDisplay, "");
String grade = StringUtils.isNotBlank(gradeLevel) ? gradeLevel : "—";
return "【绩效结果通知】\n" + "您" + period + "绩效考核已审批通过,等级为 " + grade + "。\n" + "请点击此消息进入 HR 系统查看详情。";
}
/**
* 考核结果汇总列表「发送飞书消息」按钮专用模板(sendFeishuToLeadersAction)
*
* @param periodDisplay 考核周期名称(如「2026 年 一 季度」)
*/
public static String buildEvaResultSummaryFeishuMessage(String periodDisplay) {
String period = StringUtils.defaultString(periodDisplay, "");
return "【绩效结果公示通知】您的" + period + "绩效考核结果已出,可前往 HR 系统查看。";
}
// ===== 时创意 OA 飞书待办通道(保留) =====
/**
* 拼装绩效考核 OA 待办标题
*
* @param period 考核周期
* @return 待办标题
*/
public static String buildScoreRequestName(PerfPeriodInfo period) {
String periodName = period != null ? period.getName() : "";
return "绩效考核待办:【" + periodName + "】考核周期内存在考核任务待处理,请前往绩效工作台进行评分!!";
}
/**
* 评分提交后 OA 飞书待办处理(修改上一级/新建下一级/办结)
*
* flowId 均取 GradeTaskNodeInst.id
*
* @param ctx 上下文
* @param evaObjSubmitWfLevelMap key=evaObjId, value=当前提交的节点等级 wfLevel
* @param nextHandlerMap key=evaObjId$workFlowId, value=下一节点处理人工号集合
* @param evaObjSet 提交成功的考核对象ID集合
* @param period 考核周期
* @param pcurl PC端跳转地址
* @param workFlowName 流程名称
* @param appurl 移动端跳转地址
* @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入)
*/
public static void sendAfterScoreSubmitOaTodo(Context ctx, Map
* - 新建待办:viewtype=0, isremark=0
* - 修改待办:viewtype=1, isremark=2
* - 流程办结:viewtype=1, isremark=4
*
* @param receiverId 接收人工号
* @param requestName 待办标题
* @param pcurl PC 端跳转地址
* @param workFlowName 流程名称
* @param appurl 移动端跳转地址
* @param flowId 流程 ID(GradeTaskNodeInst.id)
* @param viewtype 查看状态(0 未读/1 已读)
* @param isremark 待办状态(0 待审批/2 已办/4 办结)
* @param creatorId 创建人工号(OA 待办 creator 字段,由调用方传入)
*/
public static void sendFeishuCall(String receiverId, String requestName, String pcurl, String workFlowName, String appurl, String flowId, String viewtype, String isremark, String creatorId) {
if (StringUtils.isBlank(creatorId)) {
logger.error("OA 飞书待办 creatorId 不能为空,receiverId=" + receiverId + ", flowId=" + flowId);
return;
}
if (StringUtils.isBlank(receiverId) || StringUtils.isBlank(flowId)) {
logger.error("OA 飞书待办参数不完整,receiverId=" + receiverId + ", flowId=" + flowId);
return;
}
String receivets = String.valueOf(System.currentTimeMillis());
String nowStr = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
String apiUrl = UrlParamUtil.PRODURL;
String safePcurl = StringUtils.defaultString(pcurl);
String safeAppurl = StringUtils.isNotBlank(appurl) ? appurl : safePcurl;
String jsonBody = buildOaTodoRequestJson(receiverId, requestName, safePcurl, workFlowName, safeAppurl, flowId, receivets, nowStr, viewtype, isremark, creatorId);
logger.error("OA 飞书待办请求 URL=" + apiUrl);
logger.error("OA 飞书待办请求 Body=" + jsonBody);
HttpURLConnection conn = null;
try {
URL url = new URL(apiUrl);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setDoOutput(true);
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
OutputStream os = conn.getOutputStream();
os.write(jsonBody.getBytes("UTF-8"));
os.flush();
os.close();
int responseCode = conn.getResponseCode();
logger.error("OA 飞书待办响应码=" + responseCode + ",receiverId=" + receiverId + ",flowId=" + flowId + ",viewtype=" + viewtype + ",isremark=" + isremark);
} catch (Exception e) {
logger.error("OA 飞书待办推送异常,receiverId=" + receiverId + ",flowId=" + flowId, e);
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
/**
* 构建 OA 飞书待办请求 JSON
*/
private static String buildOaTodoRequestJson(String receiverId, String requestName, String pcurl, String workFlowName, String appurl, String flowId, String receivets, String receivedatetime, String viewtype, String isremark, String creatorId) {
return "{" + "\"requestname\":\"" + escapeJson(requestName) + "\"," + "\"creator\":\"" + escapeJson(creatorId) + "\"," + "\"receiver\":\"" + escapeJson(receiverId) + "\"," + "\"pcurl\":\"" + escapeJson(pcurl) + "\"," + "\"workflowname\":\"" + escapeJson(workFlowName) + "\"," + "\"viewtype\":\"" + escapeJson(viewtype) + "\"," + "\"appurl\":\"" + escapeJson(appurl) + "\"," + "\"syscode\":\"" + DEFAULT_SYSCODE + "\"," + "\"nodename\":\"" + DEFAULT_NODENAME + "\"," + "\"receivets\":\"" + escapeJson(receivets) + "\"," + "\"receivedatetime\":\"" + escapeJson(receivedatetime) + "\"," + "\"isremark\":\"" + escapeJson(isremark) + "\"," + "\"createdatetime\":\"" + escapeJson(receivedatetime) + "\"," + "\"flowid\":\"" + escapeJson(flowId) + "\"}";
}
/**
* JSON 字符串转义
*/
private static String escapeJson(String str) {
if (str == null) {
return "";
}
return str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
}
// ===== 飞书开放平台官方 API(SDK Transport)=====
/**
* 飞书审批 Bot 消息调用结果
*/
public static class ApprovalBotResult {
private int code;
private String msg;
private String messageId;
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
public String getMessageId() {
return messageId;
}
public boolean isSuccess() {
return code == 0;
}
}
/**
* 获取飞书 SDK Config(单例)
*
* @return Config 实例;AppId/AppSecret 未配置时返回 null
*/
private static Config getFeishuConfig() {
if (feishuConfig != null) {
return feishuConfig;
}
synchronized (FEISHU_INIT_LOCK) {
if (feishuConfig != null) {
return feishuConfig;
}
if (StringUtils.isBlank(FEISHU_APP_ID) || StringUtils.isBlank(FEISHU_APP_SECRET)) {
logger.error("飞书 AppId/AppSecret 未配置,无法创建 SDK Config");
return null;
}
Config config = new Config();
config.setAppId(FEISHU_APP_ID);
config.setAppSecret(FEISHU_APP_SECRET);
feishuConfig = config;
if (feishuClient == null) {
feishuClient = Client.newBuilder(FEISHU_APP_ID, FEISHU_APP_SECRET).build();
}
return feishuConfig;
}
}
/**
* 获取飞书 SDK Client(单例)
*
* @return Client 实例;AppId/AppSecret 未配置时返回 null
*/
private static Client getFeishuClient() {
if (feishuClient != null) {
return feishuClient;
}
getFeishuConfig();
return feishuClient;
}
/**
* 构建 SDK 请求选项(自动附带 tenant_access_token)
*
* @return RequestOptions
*/
private static RequestOptions buildRequestOptions() {
String token = getTenantAccessToken();
RequestOptions.Builder builder = RequestOptions.newBuilder();
if (StringUtils.isNotBlank(token)) {
builder.tenantAccessToken(token);
}
return builder.build();
}
/**
* 获取 tenant_access_token(飞书 SDK + 内存缓存,过期前 60 秒自动刷新)
*
* 接口文档:https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal
*
* @return tenant_access_token;失败返回 null
*/
public static String getTenantAccessToken() {
long now = System.currentTimeMillis();
if (StringUtils.isNotBlank(cachedTenantAccessToken) && now < tokenExpireAtMs - 60000) {
return cachedTenantAccessToken;
}
synchronized (TOKEN_LOCK) {
now = System.currentTimeMillis();
if (StringUtils.isNotBlank(cachedTenantAccessToken) && now < tokenExpireAtMs - 60000) {
return cachedTenantAccessToken;
}
Client client = getFeishuClient();
if (client == null) {
return null;
}
try {
InternalTenantAccessTokenReq req = InternalTenantAccessTokenReq.newBuilder().internalTenantAccessTokenReqBody(InternalTenantAccessTokenReqBody.newBuilder().appId(FEISHU_APP_ID).appSecret(FEISHU_APP_SECRET).build()).build();
InternalTenantAccessTokenResp resp = client.auth().v3().tenantAccessToken().internal(req);
if (!resp.success()) {
logger.error("获取 tenant_access_token 失败,code=" + resp.getCode() + ",msg=" + resp.getMsg() + ",reqId=" + resp.getRequestId());
return null;
}
String response = new String(resp.getRawResponse().getBody(), StandardCharsets.UTF_8);
String token = extractJsonString(response, "tenant_access_token");
if (StringUtils.isBlank(token)) {
logger.error("获取 tenant_access_token 失败,响应中无 token,response=" + response);
return null;
}
int expire = extractJsonInt(response, "expire", 7200);
cachedTenantAccessToken = token;
tokenExpireAtMs = System.currentTimeMillis() + expire * 1000L;
return token;
} catch (Exception e) {
logger.error("飞书 SDK 获取 tenant_access_token 异常", e);
return null;
}
}
}
/**
* 调用飞书审批 Bot 发送接口(SDK Transport)
*
* @param body 请求体(template_id、user_id 等)
* @return 调用结果
*/
private static ApprovalBotResult sendApprovalBotMessage(Map
* 请求体结构:custom_title、custom_content、note、actions;Bot 跳转 url 使用 appurl,pc_url 使用 pcurl
*
* 接口文档:https://open.feishu.cn/document/server-docs/approval-v4/message/send-bot-messages
*
* @param userId 接收人 user_id(当前与工号一致)
* @param uuid 幂等 ID,相同 uuid 1 小时内只发一次
* @param title 卡片标题 → custom_title
* @param content 卡片正文 → custom_content(支持 Markdown)
* @param pcUrl PC 端跳转地址
* @param mobileUrl 移动端跳转地址
* @param note 底部备注;为空时默认「来自 HR 绩效考核系统」
* @return 调用结果,成功时 messageId 可用于 updateApprovalBotMessage
*/
public static ApprovalBotResult sendCustomApprovalBotMessage(String userId, String uuid, String title, String content, String pcUrl, String mobileUrl, String note) {
ApprovalBotResult result = new ApprovalBotResult();
if (StringUtils.isBlank(userId)) {
result.code = -1;
result.msg = "userId 不能为空";
return result;
}
String safeUuid = StringUtils.isNotBlank(uuid) ? uuid : String.valueOf(System.currentTimeMillis());
String safeNote = StringUtils.defaultString(note, "来自 HR 绩效考核系统");
String safeMobileUrl = StringUtils.isNotBlank(mobileUrl) ? mobileUrl : pcUrl;
String safePcUrl = StringUtils.isNotBlank(pcUrl) ? pcUrl : safeMobileUrl;
String safeTitle = StringUtils.defaultIfBlank(title, "绩效考核提醒");
String safeContent = StringUtils.defaultIfBlank(content, safeTitle);
Map
* 适用于标准待办卡片;后续可通过 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
* 注意:仅支持更新 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
* 您(工号:{personNumber},姓名:{personName}){periodDisplay}绩效尚未完成审批评定
* 请点击此消息进入 HR 系统完成绩效等级评定。
*
*