package org.example;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
/**
* 飞书公司圈帖子数据抓取并同步至多维表格
*/
@RestController
@RequestMapping("/feishu/events")
public class MomentsBitableIntegration {
private static final Logger logger = LoggerFactory.getLogger(MomentsBitableIntegration.class);
private static final String APP_ID = "cli_a77e623b63fbd00d";
private static final String APP_SECRET = "p1y9z84vBOxSClmqn4y0CcVJPrdKeF3Y";
private static final String ENCRYPT_KEY = "eGXBxvckaDb7NdN8gC4ZW4YQishxILFi"; // 飞书事件加密密钥
private static final String TENANT_ACCESS_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
private static final String BITABLE_APP_TOKEN = "NQyxb7bxma1BNDszeFqcHLrbnje";
private static final String BITABLE_TABLE_ID = "tblycyMgokQQDoAd";
private static final String MOMENTS_POST_API = "https://open.feishu.cn/open-apis/moments/v1/posts/";
private static final String USER_INFO_API = "https://open.feishu.cn/open-apis/contact/v3/users/";
// 日期时间格式化器,用于解析多种格式的日期
private static final DateTimeFormatter[] DATE_TIME_FORMATTERS = {
DateTimeFormatter.ISO_OFFSET_DATE_TIME,
DateTimeFormatter.ISO_LOCAL_DATE_TIME,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
};
private final OkHttpClient httpClient = new OkHttpClient();
private final Gson gson = new Gson();
private final JsonParser jsonParser = new JsonParser();
private String tenantAccessToken;
private long lastTokenRefreshTime; // 记录上次令牌刷新时间
private ScheduledExecutorService scheduler;
private Map<String, PostData> postDataMap = new ConcurrentHashMap<>();
private final Map<String, String> userCache = new ConcurrentHashMap<>(); // 用户信息缓存
private final EventReceiver eventReceiver;
// 帖子数据结构
static class PostData {
String postId;
String author; // 作者姓名
String authorId; // 作者ID(open_id等)
List<String> mentionedUsers = new ArrayList<>();
int likeCount;
int dislikeCount;
double popularityScore;
String content;
long publishTimeUnix = 0; // 存储毫秒级Unix时间戳
public PostData(String postId) {
this.postId = postId;
}
}
public MomentsBitableIntegration() {
this.eventReceiver = new EventReceiver(this);
init();
scheduleWeeklyReport();
}
// 初始化飞书认证
public void init() {
try {
refreshAccessToken();
scheduler = Executors.newScheduledThreadPool(2);
// 添加定时刷新token任务(每90分钟刷新一次)
scheduler.scheduleAtFixedRate(() -> {
try {
refreshAccessToken();
} catch (Exception e) {
logger.error("刷新访问令牌失败", e);
}
}, 0, 90, TimeUnit.MINUTES);
logger.info("飞书集成服务初始化完成");
} catch (Exception e) {
logger.error("初始化失败", e);
System.exit(1);
}
}
// 刷新访问令牌
private void refreshAccessToken() throws IOException {
MediaType JSON = MediaType.get("application/json; charset=utf-8");
String json = "{\"app_id\":\"" + APP_ID + "\",\"app_secret\":\"" + APP_SECRET + "\"}";
okhttp3.RequestBody body = okhttp3.RequestBody.create(json, JSON);
Request request = new Request.Builder()
.url(TENANT_ACCESS_TOKEN_URL)
.post(body)
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
logger.error("刷新令牌请求失败: HTTP {}", response.code());
throw new IOException("刷新令牌失败: HTTP " + response.code());
}
String responseData = response.body().string();
Type type = new TypeToken<Map<String, Object>>(){}.getType();
Map<String, Object> result = gson.fromJson(responseData, type);
if (result.containsKey("tenant_access_token")) {
tenantAccessToken = (String) result.get("tenant_access_token");
lastTokenRefreshTime = System.currentTimeMillis();
logger.info("访问令牌刷新成功");
} else {
logger.error("获取访问令牌失败: {}", responseData);
throw new IOException("获取访问令牌失败: " + result.get("msg"));
}
}
}
// 检查令牌是否有效
private boolean isTokenValid() {
// 令牌有效期为2小时,提前10分钟刷新
return System.currentTimeMillis() - lastTokenRefreshTime < 110 * 60 * 1000;
}
// 处理飞书事件推送
@PostMapping("/callback")
public Map<String, Object> handleFeishuEvent(HttpServletRequest request) {
String eventJson = null;
try {
// 读取请求体
StringBuilder requestBody = new StringBuilder();
try (BufferedReader reader = request.getReader()) {
String line;
while ((line = reader.readLine()) != null) {
requestBody.append(line);
}
}
eventJson = requestBody.toString();
logger.info("收到飞书事件: {}", eventJson);
// 用于传递给EventReceiver的事件数据
String eventJsonForReceiver = null;
Map<String, Object> eventData;
// 判断是否为加密数据
if (eventJson.contains("\"encrypt\"")) {
Map<String, Object> encryptedMap = gson.fromJson(
eventJson,
new TypeToken<Map<String, Object>>() {}.getType()
);
String encryptedData = (String) encryptedMap.get("encrypt");
if (encryptedData == null || encryptedData.isEmpty()) {
logger.warn("加密数据为空: {}", eventJson);
return createErrorResponse(400, "Missing encrypt data");
}
// 解密获取原始事件数据
String decryptedJson = FeishuEncryptUtils.decrypt(ENCRYPT_KEY, encryptedData);
logger.info("解密后事件数据: {}", decryptedJson);
eventJsonForReceiver = decryptedJson;
eventData = gson.fromJson(decryptedJson, new TypeToken<Map<String, Object>>() {}.getType());
} else {
eventJsonForReceiver = eventJson;
eventData = gson.fromJson(eventJson, new TypeToken<Map<String, Object>>() {}.getType());
}
if (eventData == null) {
logger.warn("事件数据为空: {}", eventJson);
return createErrorResponse(400, "Invalid event data");
}
// 1. 优先处理URL验证事件
if (isUrlVerificationEvent(eventData)) {
return handleUrlVerification(eventData);
}
// 2. 获取header
Map<String, Object> header = getEventHeader(eventData);
if (header == null) {
logger.warn("事件header为空: {}", eventJson);
return createErrorResponse(400, "Missing event header");
}
// 3. 获取事件类型
String eventType = (String) header.get("event_type");
if (eventType == null) {
logger.warn("事件类型为空: {}", eventJson);
return createErrorResponse(400, "Missing event_type in event data");
}
// 4. 验证签名
if (!verifySignature(request, eventJson)) {
logger.warn("事件签名验证失败");
return createErrorResponse(401, "Unauthorized");
}
// 传递事件数据
eventReceiver.handleEvent(eventJsonForReceiver);
return createSuccessResponse();
} catch (Exception e) {
logger.error("处理飞书事件失败: {}", eventJson, e);
return createErrorResponse(500, "Internal Server Error");
}
}
// ===== 辅助方法 =====
private boolean isUrlVerificationEvent(Map<String, Object> eventData) {
return "url_verification".equals(eventData.get("type"));
}
private Map<String, Object> getEventHeader(Map<String, Object> eventData) {
Object headerObj = eventData.get("header");
if (headerObj instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> header = (Map<String, Object>) headerObj;
return header;
}
return null;
}
private Map<String, Object> handleUrlVerification(Map<String, Object> eventData) {
String challenge = (String) eventData.get("challenge");
if (challenge == null) {
logger.warn("URL验证请求缺少challenge参数");
return createErrorResponse(400, "Missing challenge parameter");
}
logger.info("完成URL验证,challenge: {}", challenge);
return Collections.singletonMap("challenge", challenge);
}
// 验证事件签名
private boolean verifySignature(HttpServletRequest request, String eventJson) {
if (ENCRYPT_KEY == null || ENCRYPT_KEY.isEmpty()) {
return true;
}
String timestamp = request.getHeader("X-Lark-Request-Timestamp");
String nonce = request.getHeader("X-Lark-Request-Nonce");
String signature = request.getHeader("X-Lark-Signature");
if (timestamp == null || nonce == null || signature == null) {
logger.warn("缺少签名参数:timestamp={}, nonce={}, signature={}",
timestamp, nonce, signature);
return false;
}
String signStr = timestamp + nonce + ENCRYPT_KEY + eventJson;
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = md.digest(signStr.getBytes(StandardCharsets.UTF_8));
String calculatedSignature = bytesToHex(hashBytes);
return calculatedSignature.equals(signature);
} catch (NoSuchAlgorithmException e) {
logger.error("签名计算失败", e);
return false;
}
}
// 字节数组转十六进制字符串
private String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = String.format("%02x", b);
hexString.append(hex);
}
return hexString.toString();
}
// 处理表情互动创建事件
public void handleReactionCreatedEvent(Map<String, Object> eventData) {
try {
Map<String, Object> event = (Map<String, Object>) eventData.get("event");
if (event == null) {
logger.warn("无效的表情互动事件: {}", eventData);
return;
}
String postId = (String) event.get("entity_id");
String reactionType = (String) event.get("type");
if (postId == null || reactionType == null) {
logger.warn("表情互动事件缺少必要字段: postId={}, reactionType={}", postId, reactionType);
return;
}
PostData postData = postDataMap.computeIfAbsent(postId, PostData::new);
// 核心增强:通过帖子ID获取作者信息
if (postData.author == null || postData.authorId == null) {
try {
// 第一步:获取帖子详情(包含作者ID)
fetchPostDetails(postData);
// 第二步:如果获取到了作者ID但还没有姓名,则调用用户接口
if (postData.authorId != null && (postData.author == null || postData.author.equals("未知作者"))) {
logger.info("通过表情互动事件获取作者信息: 帖子ID={} → 作者ID={}", postId, postData.authorId);
postData.author = getUserName(postData.authorId);
logger.info("获取作者姓名成功: {} → {}", postData.authorId, postData.author);
}
} catch (Exception e) {
logger.error("通过表情互动事件获取作者信息失败", e);
}
}
if ("THUMBSUP".equals(reactionType)) {
postData.likeCount++;
logger.info("帖子 {} 收到新点赞,当前点赞数: {}", postId, postData.likeCount);
}
updatePopularityScore(postData);
} catch (Exception e) {
logger.error("处理表情互动事件失败", e);
}
}
// 处理表情互动删除事件
public void handleReactionDeletedEvent(Map<String, Object> eventData) {
try {
Map<String, Object> event = (Map<String, Object>) eventData.get("event");
if (event == null) {
logger.warn("无效的表情互动删除事件: {}", eventData);
return;
}
String postId = (String) event.get("entity_id");
String reactionType = (String) event.get("type");
if (postId == null || reactionType == null) {
logger.warn("表情互动删除事件缺少必要字段: postId={}, reactionType={}", postId, reactionType);
return;
}
PostData postData = postDataMap.computeIfAbsent(postId, PostData::new);
// 通过帖子ID获取作者信息
if (postData.author == null || postData.authorId == null) {
try {
fetchPostDetails(postData);
if (postData.authorId != null && (postData.author == null || postData.author.equals("未知作者"))) {
postData.author = getUserName(postData.authorId);
}
} catch (Exception e) {
logger.error("获取帖子详情失败,但将继续处理事件", e);
}
}
if ("THUMBSUP".equals(reactionType)) {
if (postData.likeCount > 0) {
postData.likeCount--;
logger.info("帖子 {} 取消点赞,当前点赞数: {}", postId, postData.likeCount);
} else {
logger.warn("帖子 {} 点赞数为0,但收到取消点赞事件", postId);
}
}
updatePopularityScore(postData);
} catch (Exception e) {
logger.error("处理表情互动删除事件失败", e);
}
}
// 处理点踩创建事件
public void handleDislikeCreatedEvent(Map<String, Object> eventData) {
try {
Map<String, Object> event = (Map<String, Object>) eventData.get("event");
if (event == null) {
logger.warn("无效的点踩事件: {}", eventData);
return;
}
String postId = (String) event.get("entity_id");
if (postId == null) {
logger.warn("点踩事件缺少必要字段: {}", eventData);
return;
}
PostData postData = postDataMap.computeIfAbsent(postId, PostData::new);
// 通过帖子ID获取作者信息
if (postData.author == null || postData.authorId == null) {
try {
fetchPostDetails(postData);
if (postData.authorId != null && (postData.author == null || postData.author.equals("未知作者"))) {
postData.author = getUserName(postData.authorId);
}
} catch (Exception e) {
logger.error("获取帖子详情失败,但将继续处理事件", e);
}
}
postData.dislikeCount++;
logger.info("帖子 {} 收到新点踩,当前点踩数: {}", postId, postData.dislikeCount);
updatePopularityScore(postData);
} catch (Exception e) {
logger.error("处理点踩事件失败", e);
}
}
// 处理点踩删除事件
public void handleDislikeDeletedEvent(Map<String, Object> eventData) {
try {
Map<String, Object> event = (Map<String, Object>) eventData.get("event");
if (event == null) {
logger.warn("无效的点踩删除事件: {}", eventData);
return;
}
String postId = (String) event.get("entity_id");
if (postId == null) {
logger.warn("点踩删除事件缺少必要字段: {}", eventData);
return;
}
PostData postData = postDataMap.computeIfAbsent(postId, PostData::new);
// 通过帖子ID获取作者信息
if (postData.author == null || postData.authorId == null) {
try {
fetchPostDetails(postData);
if (postData.authorId != null && (postData.author == null || postData.author.equals("未知作者"))) {
postData.author = getUserName(postData.authorId);
}
} catch (Exception e) {
logger.error("获取帖子详情失败,但将继续处理事件", e);
}
}
if (postData.dislikeCount > 0) {
postData.dislikeCount--;
logger.info("帖子 {} 取消点踩,当前点踩数: {}", postId, postData.dislikeCount);
} else {
logger.warn("帖子 {} 点踩数为0,但收到取消点踩事件", postId);
}
updatePopularityScore(postData);
} catch (Exception e) {
logger.error("处理点踩删除事件失败", e);
}
}
public void handlePostCreatedEvent(Map<String, Object> eventData) {
try {
Map<String, Object> event = (Map<String, Object>) eventData.get("event");
if (event == null) {
logger.warn("无效的帖子创建事件: {}", eventData);
return;
}
String postId = (String) event.get("id");
if (postId == null) {
logger.warn("帖子创建事件缺少必要字段: {}", eventData);
return;
}
PostData postData = postDataMap.computeIfAbsent(postId, PostData::new);
try {
fetchPostDetails(postData);
} catch (Exception e) {
logger.error("获取帖子详情失败", e);
}
Map<String, Object> userIdentity = (Map<String, Object>) event.get("user_id");
String eventUserId = null;
if (userIdentity != null) {
eventUserId = (String) userIdentity.get("open_id");
if (eventUserId == null) {
eventUserId = (String) userIdentity.get("union_id");
}
}
if (postData.author == null && eventUserId != null) {
try {
postData.author = getUserName(eventUserId);
} catch (Exception e) {
logger.error("获取用户姓名失败", e);
postData.author = eventUserId;
}
}
logger.info("新帖子创建: {}, 作者ID: {}, 发布时间: {}",
postId, eventUserId, postData.publishTimeUnix);
logger.info("作者姓名: {}", postData.author);
} catch (Exception e) {
logger.error("处理帖子创建事件失败", e);
}
}
// 判断用户ID类型
private String determineUserIdType(String userId) {
if (userId.startsWith("ou_")) return "open_id";
if (userId.startsWith("u_")) return "user_id";
if (userId.startsWith("on_")) return "union_id";
return null;
}
// 获取用户姓名(带缓存和重试机制)
private String getUserName(String userId) throws IOException {
// 检查缓存
if (userCache.containsKey(userId)) {
String cachedName = userCache.get(userId);
logger.info("用户信息命中缓存: {} → {}", userId, cachedName);
// 调试日志:检查缓存中的名称是否是中文
if (!isValidChineseName(cachedName)) {
logger.warn("缓存中的名称不是中文: {} → {}", userId, cachedName);
}
return cachedName;
}
if (userId == null || userId.isEmpty()) {
logger.warn("用户ID为空");
return "无效ID";
}
String userType = determineUserIdType(userId);
if (userType == null) {
logger.warn("不支持的用户ID类型: {}", userId);
return "未知用户";
}
int retries = 3;
while (retries-- > 0) {
try {
String url = USER_INFO_API + userId +
"?department_id_type=open_department_id&user_id_type=" + userType;
logger.debug("调用用户信息API: {}", url);
Request request = new Request.Builder()
.url(url)
.header("Authorization", "Bearer " + tenantAccessToken)
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
logger.debug("用户信息接口响应: HTTP {}", response.code());
if (!response.isSuccessful()) {
logger.error("用户信息接口错误: HTTP {} - {}",
response.code(), response.message());
if (response.code() == 429) {
Thread.sleep(1000);
continue;
} else if (response.code() == 404) {
logger.warn("用户不存在: {}", userId);
return "已离职用户";
}
throw new IOException("获取用户信息失败: HTTP " + response.code());
}
String responseData = response.body().string();
logger.debug("用户信息API原始响应: {}", responseData); // 添加原始响应日志
JsonObject result = jsonParser.parse(responseData).getAsJsonObject();
if (result.has("code") && result.get("code").getAsInt() != 0) {
logger.error("飞书API返回错误: {}", responseData);
continue;
}
JsonObject data = result.getAsJsonObject("data");
if (data != null && data.has("user")) {
JsonObject user = data.getAsJsonObject("user");
// === 详细调试日志开始 ===
logger.info("完整用户对象: {}", user.toString()); // 记录完整用户对象
String userName = null;
String source = "未知来源";
// 1. 优先使用name字段
if (user.has("name")) {
JsonElement nameElement = user.get("name");
if (nameElement.isJsonPrimitive()) {
userName = nameElement.getAsString().trim();
source = "name字段";
logger.info("从{}获取用户名: {}", source, userName);
// 检查是否是有效中文名
if (isValidChineseName(userName)) {
logger.debug("name字段是有效中文名");
} else {
logger.warn("name字段不是中文名: {}", userName);
}
}
}
// 2. 尝试custom_attrs获取中文名
if ((userName == null || !isValidChineseName(userName)) && user.has("custom_attrs")) {
JsonObject customAttrs = user.getAsJsonObject("custom_attrs");
logger.debug("检查custom_attrs: {}", customAttrs);
for (String key : customAttrs.keySet()) {
// 查找包含"中文"或"姓名"的自定义字段
if (key.contains("中文") || key.contains("姓名")) {
JsonElement attrElement = customAttrs.get(key);
if (attrElement.isJsonPrimitive()) {
String candidate = attrElement.getAsString().trim();
logger.debug("找到可能的中文名字段: {} = {}", key, candidate);
if (isValidChineseName(candidate)) {
userName = candidate;
source = "custom_attrs." + key;
logger.info("从{}获取中文名: {}", source, userName);
break;
}
}
}
}
}
// 3. 尝试en_name字段
if ((userName == null || userName.isEmpty()) && user.has("en_name")) {
JsonElement enNameElement = user.get("en_name");
if (enNameElement.isJsonPrimitive()) {
userName = enNameElement.getAsString().trim();
source = "en_name字段";
logger.info("从{}获取用户名: {}", source, userName);
}
}
// 4. 回退到user_id
if ((userName == null || userName.isEmpty()) && user.has("user_id")) {
JsonElement userIdElement = user.get("user_id");
if (userIdElement.isJsonPrimitive()) {
userName = userIdElement.getAsString().trim();
source = "user_id字段";
logger.info("从{}获取用户名: {}", source, userName);
}
}
// 5. 最终回退
if (userName == null || userName.isEmpty()) {
userName = "未知用户";
source = "默认值";
logger.warn("无法获取有效用户名,使用默认值");
}
// 检查最终用户名是否是中文
if (!isValidChineseName(userName)) {
logger.warn("最终用户名不是中文: {} → {} (来源: {})",
userId, userName, source);
logger.warn("完整用户对象: {}", user.toString());
} else {
logger.info("获取到中文名: {} → {}", userId, userName);
}
// === 详细调试日志结束 ===
// 缓存结果
logger.info("缓存用户信息: {} → {}", userId, userName);
userCache.put(userId, userName);
logger.info("最终确定的用户名: {} → {}", userId, userName);
return userName;
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("用户信息请求被中断", e);
} catch (Exception e) {
logger.error("获取用户信息异常", e);
if (retries == 0) {
logger.error("最终获取用户信息失败: {}", userId, e);
return "未知用户";
}
try {
Thread.sleep(2000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
return "未知用户";
}
// 检查是否是有效中文名
private boolean isValidChineseName(String name) {
if (name == null || name.isEmpty()) return false;
// 简单检查:包含中文字符即为有效
return name.matches(".*[\\u4e00-\\u9fa5]+.*");
}
// 获取帖子详情(严格使用user_id字段)
private void fetchPostDetails(PostData postData) throws IOException {
Request request = new Request.Builder()
.url(MOMENTS_POST_API + postData.postId)
.header("Authorization", "Bearer " + tenantAccessToken)
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("获取帖子详情失败: HTTP " + response.code());
}
String responseData = response.body().string();
logger.debug("帖子详情API响应: {}", responseData);
JsonObject result = jsonParser.parse(responseData).getAsJsonObject();
JsonObject data = result.getAsJsonObject("data");
if (data != null) {
JsonObject post = data.getAsJsonObject("post");
if (post != null) {
// ===== 严格使用user_id字段 =====
String authorId = null;
String authorSource = "unknown";
// 只使用user_id字段获取作者ID
if (post.has("user_id")) {
JsonElement userIdElem = post.get("user_id");
// 处理字符串格式的user_id
if (userIdElem.isJsonPrimitive()) {
authorId = userIdElem.getAsString();
authorSource = "user_id";
logger.info("从user_id字段获取作者ID: {}", authorId);
// 标准化ID格式(确保以ou_开头)
if (!authorId.startsWith("ou_") && authorId.length() == 32) {
authorId = "ou_" + authorId;
logger.info("标准化作者ID格式: {} → {}", authorId, authorId);
}
}
// 处理对象格式的user_id
else if (userIdElem.isJsonObject()) {
JsonObject userIdObj = userIdElem.getAsJsonObject();
if (userIdObj.has("open_id")) {
authorId = userIdObj.get("open_id").getAsString();
authorSource = "user_id.open_id";
logger.info("从user_id.open_id获取作者ID: {}", authorId);
}
}
}
// 存储作者ID
if (authorId != null) {
postData.authorId = authorId;
logger.info("获取到帖子作者ID: {} (来源: {})", authorId, authorSource);
// 获取作者姓名
if (postData.author == null) {
try {
postData.author = getUserName(authorId);
logger.info("通过作者ID获取姓名: {} → {}", authorId, postData.author);
} catch (Exception e) {
logger.error("获取作者姓名失败", e);
postData.author = "未知作者";
}
}
} else {
logger.warn("无法确定帖子作者ID: {}", postData.postId);
}
// ===== 其他字段处理 =====
// 提及用户处理
if (post.has("mentions")) {
for (JsonElement mention : post.getAsJsonArray("mentions")) {
JsonObject mentionObj = mention.getAsJsonObject();
String mentionedUserId = null;
if (mentionObj.has("open_id")) {
mentionedUserId = mentionObj.get("open_id").getAsString();
} else if (mentionObj.has("user_id")) {
mentionedUserId = mentionObj.get("user_id").getAsString();
} else if (mentionObj.has("id")) {
mentionedUserId = mentionObj.get("id").getAsString();
}
if (mentionedUserId != null) {
postData.mentionedUsers.add(getUserName(mentionedUserId));
} else if (mentionObj.has("name")) {
postData.mentionedUsers.add(mentionObj.get("name").getAsString());
}
}
}
// 内容处理
if (post.has("content")) {
// 解析富文本内容为纯文本
String contentJson = post.get("content").getAsString();
postData.content = parseRichTextToPlainText(contentJson);
}
// 时间处理逻辑
if (post.has("create_time")) {
try {
JsonElement elem = post.get("create_time");
if (elem.isJsonPrimitive()) {
if (elem.getAsJsonPrimitive().isString()) {
String timeStr = elem.getAsString();
// 尝试多种格式解析
postData.publishTimeUnix = parseTimeToUnixMillis(timeStr);
logger.info("解析发布时间: {} → {}", timeStr, postData.publishTimeUnix);
} else if (elem.getAsJsonPrimitive().isNumber()) {
postData.publishTimeUnix = elem.getAsLong() * 1000;
logger.info("解析发布时间戳: {}", postData.publishTimeUnix);
}
}
} catch (Exception e) {
logger.error("解析create_time失败", e);
}
}
}
}
} catch (Exception e) {
logger.error("解析帖子详情失败", e);
throw new IOException("解析帖子详情失败: " + e.getMessage());
}
}
// 发布时间解析方法
private long parseTimeToUnixMillis(String timeStr) {
if (timeStr == null || timeStr.isEmpty()) {
return 0;
}
// 1. 尝试解析为数字(秒级时间戳)
try {
long seconds = Long.parseLong(timeStr);
return seconds * 1000;
} catch (NumberFormatException e) {
// 不是数字,继续尝试其他格式
}
// 2. 尝试多种日期格式
for (DateTimeFormatter formatter : DATE_TIME_FORMATTERS) {
try {
// 尝试带时区解析
if (formatter == DateTimeFormatter.ISO_OFFSET_DATE_TIME) {
ZonedDateTime zdt = ZonedDateTime.parse(timeStr, formatter);
return zdt.toInstant().toEpochMilli();
}
// 尝试本地时间解析(默认时区)
LocalDateTime ldt = LocalDateTime.parse(timeStr, formatter);
return ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
} catch (DateTimeParseException e) {
}
}
// 3. 尝试其他可能的格式
try {
// 尝试解析为毫秒级时间戳
return Long.parseLong(timeStr);
} catch (NumberFormatException e) {
// 解析失败
}
logger.warn("无法解析时间字符串: {}", timeStr);
return 0;
}
// 解析飞书富文本格式为纯文本
private String parseRichTextToPlainText(String richTextJson) {
try {
if (richTextJson == null || richTextJson.isEmpty()) {
return "";
}
JsonArray blocks = jsonParser.parse(richTextJson).getAsJsonArray();
StringBuilder plainText = new StringBuilder();
for (JsonElement block : blocks) {
if (block.isJsonArray()) {
JsonArray elements = block.getAsJsonArray();
for (JsonElement element : elements) {
if (element.isJsonObject()) {
JsonObject obj = element.getAsJsonObject();
if (obj.has("tag") && obj.has("text")) {
String tag = obj.get("tag").getAsString();
String text = obj.get("text").getAsString();
// 处理不同类型的标签
if ("text".equals(tag)) {
plainText.append(text);
} else if ("hashtag".equals(tag)) {
// 保留话题标签的文本内容
plainText.append("#").append(text).append(" ");
}
// 可以根据需要添加更多标签类型的处理
}
}
}
// 块之间添加换行
plainText.append("\n");
}
}
return plainText.toString().trim();
} catch (Exception e) {
logger.error("解析富文本失败: {}", richTextJson, e);
// 如果解析失败,返回原始内容
return richTextJson;
}
}
// 更新帖子热度值
private void updatePopularityScore(PostData postData) {
double newScore = postData.likeCount * 1.0 - postData.dislikeCount * 0.5;
double oldScore = postData.popularityScore;
postData.popularityScore = newScore;
logger.info("帖子 {} 热度更新: {} → {}",
postData.postId, oldScore, newScore);
}
// 安排报告任务(每10分钟生成一次报告并清理缓存)
public void scheduleWeeklyReport() {
// 立即执行第一次报告生成和缓存清理
long initialDelay = 0;
long period = 10; // 10分钟间隔
// 每10分钟生成报告
scheduler.scheduleAtFixedRate(() -> {
try {
logger.info("开始执行定期报告生成");
generateWeeklyReport();
} catch (Exception e) {
logger.error("生成报告失败", e);
}
}, initialDelay, period, TimeUnit.MINUTES);
// 每10分钟清理用户缓存
scheduler.scheduleAtFixedRate(() -> {
logger.info("开始清理用户缓存");
int cacheSize = userCache.size();
userCache.clear();
logger.info("用户缓存已清除,共清理 {} 条记录", cacheSize);
}, initialDelay, period, TimeUnit.MINUTES);
LocalDateTime firstExecution = LocalDateTime.now().plusMinutes(initialDelay);
logger.info("任务已安排:报告生成和缓存清理将于 {} 开始执行,每 {} 分钟运行一次",
firstExecution.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")),
period);
}
// 生成周报并同步至多维表格
private void generateWeeklyReport() throws IOException {
logger.info("开始生成周报...");
long insertionTime = System.currentTimeMillis(); // 获取统一的插入时间
List<PostData> topPosts = postDataMap.values().stream()
.sorted(Comparator.comparingDouble(p -> -p.popularityScore))
.limit(3)
.collect(Collectors.toList());
if (topPosts.isEmpty()) {
logger.info("本周没有帖子数据");
return;
}
List<Map<String, Object>> records = new ArrayList<>();
for (int i = 0; i < topPosts.size(); i++) {
PostData post = topPosts.get(i);
Map<String, Object> record = new HashMap<>();
Map<String, Object> fields = new HashMap<>();
fields.put("板块信息", "夸一夸");
fields.put("帖子正文", post.content);
fields.put("被@的人", String.join(", ", post.mentionedUsers));
fields.put("排名", i + 1);
fields.put("综合热度值", post.popularityScore);
fields.put("点赞数量", post.likeCount);
fields.put("作者", post.author);
// 使用帖子自身的发布时间
fields.put("发布时间", post.publishTimeUnix);
record.put("fields", fields);
records.add(record);
logger.info("添加记录: 帖子ID={}, 作者={}, 发布时间={}",
post.postId, post.author, post.publishTimeUnix);
}
insertRecordsToBitable(records);
postDataMap.clear();
logger.info("周报生成完成,已同步至多维表格");
}
// 插入记录到多维表格(增强错误处理)
private void insertRecordsToBitable(List<Map<String, Object>> records) throws IOException {
// 检查令牌有效性
if (!isTokenValid()) {
logger.warn("访问令牌即将过期,正在刷新...");
refreshAccessToken();
}
String url = "https://open.feishu.cn/open-apis/bitable/v1/apps/" +
BITABLE_APP_TOKEN + "/tables/" + BITABLE_TABLE_ID + "/records/batch_create";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("records", records);
MediaType JSON = MediaType.get("application/json; charset=utf-8");
okhttp3.RequestBody body = okhttp3.RequestBody.create(gson.toJson(requestBody), JSON);
Request request = new Request.Builder()
.url(url)
.header("Authorization", "Bearer " + tenantAccessToken)
.post(body)
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
String errorBody = response.body().string();
logger.error("多维表格插入失败: HTTP {} - {}", response.code(), errorBody);
// 处理403错误:尝试刷新令牌并重试
if (response.code() == 403) {
logger.warn("访问令牌可能失效,尝试刷新并重试...");
refreshAccessToken();
Request newRequest = request.newBuilder()
.header("Authorization", "Bearer " + tenantAccessToken)
.build();
try (Response retryResponse = httpClient.newCall(newRequest).execute()) {
if (!retryResponse.isSuccessful()) {
String retryErrorBody = retryResponse.body().string();
logger.error("重试插入失败: HTTP {} - {}", retryResponse.code(), retryErrorBody);
throw new IOException("重试插入失败: HTTP " + retryResponse.code());
}
logger.info("重试插入成功");
return;
}
}
throw new IOException("插入多维表格失败: HTTP " + response.code());
}
String responseData = response.body().string();
logger.info("插入多维表格成功: {}", responseData);
}
}
// 错误响应
private Map<String, Object> createErrorResponse(int code, String msg) {
Map<String, Object> result = new HashMap<>();
result.put("code", code);
result.put("msg", msg);
return result;
}
// 成功响应
private Map<String, Object> createSuccessResponse() {
Map<String, Object> result = new HashMap<>();
result.put("code", 0);
result.put("msg", "success");
return result;
}
}
目前接口返回200
最新发布