昨天的成就
工单系统 CRUD 已经打通,维修工单、巡检工单、保养工单、检测工单四类工单各自拥有独立的 Servlet 和数据表。工单编号自动生成功能正常(如 WX20260615001 ),状态流转通过时间戳字段记录,模糊搜索 + 状态筛选 + 分页的组合查询测试通过。
遇到的困难
困难一:飞书开放平台 API 版本与认证机制 。飞书的接口需要先获取 tenant_access_token ,而且 token 有有效期,每次请求都重新获取会触发频率限制。需要设计一个缓存机制,在 token 过期前复用。
困难二:工程师姓名与飞书 open_id 的映射问题 。系统中存储的是"赵工程师"这种中文姓名,但飞书消息推送必须用 ou_xxxx 格式的 open_id 。需要维护一个映射表,而且在工单指派场景下要能双向解析(姓名 → id / id → 姓名)。
困难三:交互式卡片(Card)的 JSON 格式嵌套太深 。飞书卡片支持富文本、按钮交互,但手工拼接 JSON 很容易漏掉引号或逗号,调试时需要反复对照飞书卡片搭建工具的示例。
困难四:长连接模式 vs 回调模式 。飞书 SDK 同时支持 WebSocket 长连接和 HTTP 事件回调两种接收消息的方式,初期只打算实现回调模式,但 WebSocket 模式调试更方便(不需要公网回调地址),决定两种模式都支持。
今天的任务
- 实现 FeishuConfig 配置类 :集中管理 App ID、App Secret、接口 URL
- 实现 FeishuService 核心服务类 :token 缓存、文本消息发送、交互式卡片发送、工程师姓名 ↔ open_id 双向映射
- 实现 notifyAssigneeByCard :工单创建时自动向被指派工程师推送卡片
- 搭建 FeishuWebSocketService :通过飞书 SDK 建立长连接,接收用户消息
代码嵌入
FeishuConfig.java — 飞书应用配置
package com.yunwei.util;
public class FeishuConfig {
public static final String APP_ID = "cli_aa96f065b038dcd9";
public static final String APP_SECRET = "wSFGQk6XvqLiRBvBYd2FOcGvPiHNVS6O";
public static final String TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
public static final String MESSAGE_URL = "https://open.feishu.cn/open-apis/im/v1/messages";
}
FeishuService.java — Token 缓存 & open_id 映射
public class FeishuService {
private static String tenantAccessToken = null;
private static long tokenExpireTime = 0;
// ========= 工程师 ↔ 飞书 open_id 映射 =========
// 新增工程师时在这里加一行;后续可迁移到数据库表
private static final Map<String, String> ENGINEER_OPEN_ID = new HashMap<String, String>() {{
put("赵工程师", "ou_f9e92b592e650988738707ddbe717c8e");
put("我", null); // "派给我" 会通过发送者 open_id 回填
}};
public static String getTenantAccessToken() {
// 缓存未过期直接返回,避免频繁请求
if (tenantAccessToken != null && System.currentTimeMillis() < tokenExpireTime) {
return tenantAccessToken;
}
try {
URL url = new URL(FeishuConfig.TOKEN_URL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
String requestJson = "{"app_id":"" + FeishuConfig.APP_ID
+ "","app_secret":"" + FeishuConfig.APP_SECRET + ""}";
try (OutputStream os = conn.getOutputStream()) {
os.write(requestJson.getBytes(StandardCharsets.UTF_8));
}
// 解析响应中的 tenant_access_token 和 expire(秒)
BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) sb.append(line);
reader.close();
// 简易 JSON 字段提取
String token = extractField(sb.toString(), "tenant_access_token");
int expire = Integer.parseInt(extractField(sb.toString(), "expire"));
tenantAccessToken = token;
// 提前 5 分钟过期,避免临界时间窗失效
tokenExpireTime = System.currentTimeMillis() + (expire - 300) * 1000L;
return token;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
姓名 → open_id 解析方法(支持模糊匹配):
/** 根据中文姓名获取飞书 open_id;找不到返回 null */
public static String resolveAssignToOpenId(String assignTo, String senderOpenId) {
if (assignTo == null || assignTo.isEmpty()) return null;
String key = assignTo.trim();
// "我 / 本人" → 回退到发送者自己的 open_id
if ("我".equals(key) || "本人".equals(key)) return senderOpenId;
// 精确匹配
if (ENGINEER_OPEN_ID.containsKey(key)) return ENGINEER_OPEN_ID.get(key);
// 模糊包含匹配
for (Map.Entry<String, String> e : ENGINEER_OPEN_ID.entrySet()) {
if (e.getValue() == null) continue;
if (e.getKey().equals(key) || e.getKey().contains(key) || key.contains(e.getKey())) {
return e.getValue();
}
}
return null;
}
/** 根据 open_id 反向查找工程师姓名(用于"我的工单"场景) /
public static String resolveEngineerNameFromOpenId(String openId) {
if (openId == null || openId.isEmpty()) return null;
for (Map.Entry<String, String> e : ENGINEER_OPEN_ID.entrySet()) {
if (openId.equals(e.getValue())) return e.getKey();
}
return null;
}
发送交互式卡片通知 — notifyAssigneeByCard:
/* 向被指派工程师推送"新工单到了"的卡片 */
public static void notifyAssigneeByCard(String assignName, String typeName,
String orderNo, String deviceName, String location,
String priority, String description, String createBy) {
String openId = resolveAssignToOpenId(assignName, null);
if (openId == null || openId.isEmpty()) {
System.out.println("[飞书] 跳过通知:找不到 " + assignName + " 对应的 open_id");
return;
}
// 构建卡片内容(简化版:展示关键信息 + 操作按钮)
String card = buildSimpleCard(
"✅ 你收到一条新的" + typeName,
"blue",
new String[][]{
{"工单/计划编号", orderNo == null || orderNo.isEmpty() ? "—" : orderNo},
{"类型", typeName},
{"设备", deviceName == null ? "—" : deviceName},
{"位置", location == null ? "—" : location},
{"优先级", priority == null || priority.isEmpty() ? "中" : priority},
{"描述", description == null ? "—" : description},
{"创建人", createBy == null ? "系统用户" : createBy}
}
);
boolean ok = sendInteractiveCard(openId, "open_id", card);
System.out.println("[飞书] 向 " + assignName + "(" + openId + ") 发送"
+ (ok ? "✅ 成功" : "❌ 失败") + " 卡片通知");
}
sendInteractiveCard — 通过 HTTP POST 调飞书接口发送卡片:
public static boolean sendInteractiveCard(String receiveId, String receiveIdType, String cardContentJson) {
String token = getTenantAccessToken();
if (token == null) { System.out.println("获取飞书 token 失败!"); return false; }
try {
URL url = new URL(FeishuConfig.MESSAGE_URL + "?receive_id_type=" + receiveIdType);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Authorization", "Bearer " + token);
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
String requestJson = "{"receive_id":"" + receiveId
+ "","msg_type":"interactive","content":"" + escapeJson(cardContentJson) + ""}";
try (OutputStream os = conn.getOutputStream()) {
os.write(requestJson.getBytes(StandardCharsets.UTF_8));
}
int responseCode = conn.getResponseCode();
return responseCode >= 200 && responseCode < 300;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
