diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java index b2d2564..6b72145 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java @@ -16,6 +16,8 @@ import java.security.MessageDigest; import java.util.Arrays; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; /** * 企微回调控制器 @@ -30,6 +32,12 @@ public class ArchiveCallbackController { @Autowired private ArchivePullService archivePullService; + private final ExecutorService callbackExecutor = Executors.newFixedThreadPool(4, r -> { + Thread t = new Thread(r, "archive-callback-" + System.currentTimeMillis()); + t.setDaemon(true); + return t; + }); + @Value("${wecom.archive.callback-token:}") private String callbackToken; @@ -134,7 +142,7 @@ public class ArchiveCallbackController { } catch (Exception e) { log.error("回调触发拉取失败: {}", e.getMessage(), e); } - }); + }, callbackExecutor); } return Result.success("success"); diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java index 504ff3f..0a19773 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java @@ -6,6 +6,7 @@ import com.artedu.archive.entity.Staff; import com.artedu.archive.mapper.ArchiveMessageMapper; import com.artedu.archive.mapper.CustomerMapper; import com.artedu.archive.mapper.StaffMapper; +import com.artedu.archive.client.WeComApiClient; import com.artedu.archive.service.ArchivePullService; import com.artedu.archive.vo.ArchiveMessageVO; import com.artedu.common.result.PageResult; @@ -15,6 +16,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -58,6 +60,12 @@ public class ArchiveMessageController { @Autowired private ArchivePullService archivePullService; + @Autowired + private WeComApiClient weComApiClient; + + @Value("${wecom.archive.corp-id}") + private String corpId; + private static final String MEDIA_CACHE_DIR = "/app/media"; /** @@ -158,7 +166,31 @@ public class ArchiveMessageController { wrapper.eq(ArchiveMessage::getMsgtype, msgtype); } if (fromUser != null && !fromUser.isEmpty()) { - wrapper.eq(ArchiveMessage::getFromUser, fromUser); + // 支持发送人ID精确匹配 + 昵称模糊匹配 + Set matchedIds = new java.util.HashSet<>(); + matchedIds.add(fromUser); + + // 员工昵称模糊匹配 + LambdaQueryWrapper staffWrapper = new LambdaQueryWrapper<>(); + staffWrapper.eq(Staff::getCorpId, corpId).like(Staff::getName, fromUser); + List staffs = staffMapper.selectList(staffWrapper); + if (staffs != null) { + for (Staff s : staffs) { + matchedIds.add(s.getStaffId()); + } + } + + // 客户昵称模糊匹配 + LambdaQueryWrapper customerWrapper = new LambdaQueryWrapper<>(); + customerWrapper.eq(Customer::getCorpId, corpId).like(Customer::getName, fromUser); + List customers = customerMapper.selectList(customerWrapper); + if (customers != null) { + for (Customer c : customers) { + matchedIds.add(c.getCustomerId()); + } + } + + wrapper.in(ArchiveMessage::getFromUser, matchedIds); } if (fromRole != null && !fromRole.isEmpty()) { wrapper.eq(ArchiveMessage::getFromRole, fromRole); @@ -196,10 +228,31 @@ public class ArchiveMessageController { private void enrichSenderNames(List list) { if (list == null || list.isEmpty()) return; - Set userIds = list.stream() - .map(ArchiveMessageVO::getFromUser) - .filter(id -> id != null && !id.isEmpty()) - .collect(Collectors.toSet()); + // 收集所有需要查询昵称的userId(发送者 + 接收者) + Set userIds = new java.util.HashSet<>(); + for (ArchiveMessageVO vo : list) { + // 发送者 + String from = vo.getFromUser(); + if (from != null && !from.isEmpty()) { + userIds.add(from); + } + // 接收者(解析JSON数组) + String to = vo.getToUser(); + if (to != null && !to.isEmpty()) { + if (to.startsWith("[")) { + try { + List toList = JsonUtils.fromJsonList(to, String.class); + if (toList != null) { + userIds.addAll(toList); + } + } catch (Exception e) { + userIds.add(to); + } + } else { + userIds.add(to); + } + } + } if (userIds.isEmpty()) return; List staffs = staffMapper.selectList( @@ -216,12 +269,67 @@ public class ArchiveMessageController { nameMap.putAll(staffNameMap); nameMap.putAll(customerNameMap); - for (ArchiveMessageVO vo : list) { - String uid = vo.getFromUser(); - if (uid != null && !uid.isEmpty()) { - vo.setFromUserName(nameMap.getOrDefault(uid, uid)); + // 对未找到昵称或昵称为ID的外部客户,实时调用企微API获取并更新数据库 + for (String uid : userIds) { + String currentName = nameMap.get(uid); + if (isExternalUserId(uid) && (currentName == null || currentName.equals(uid))) { + try { + Map detail = weComApiClient.getExternalContact(uid); + if (detail != null && !detail.isEmpty()) { + Map contact = (Map) detail.get("external_contact"); + if (contact != null) { + String name = (String) contact.get("name"); + if (name != null && !name.isEmpty()) { + nameMap.put(uid, name); + // 更新数据库 + Customer customer = customerMapper.selectOne( + new LambdaQueryWrapper() + .eq(Customer::getCustomerId, uid) + .eq(Customer::getCorpId, "wwd483c2fba24ae30a") + ); + if (customer != null) { + customer.setName(name); + customerMapper.updateById(customer); + } + } + } + } + } catch (Exception e) { + log.debug("实时获取客户昵称失败: {}", uid); + } } } + + for (ArchiveMessageVO vo : list) { + // 填充发送者昵称 + String from = vo.getFromUser(); + if (from != null && !from.isEmpty()) { + vo.setFromUserName(nameMap.getOrDefault(from, from)); + } + // 填充接收者昵称 + String to = vo.getToUser(); + if (to != null && !to.isEmpty()) { + if (to.startsWith("[")) { + try { + List toList = JsonUtils.fromJsonList(to, String.class); + if (toList != null && !toList.isEmpty()) { + String toNames = toList.stream() + .map(id -> nameMap.getOrDefault(id, id)) + .collect(Collectors.joining(", ")); + vo.setToUserName(toNames); + } + } catch (Exception e) { + vo.setToUserName(nameMap.getOrDefault(to, to)); + } + } else { + vo.setToUserName(nameMap.getOrDefault(to, to)); + } + } + } + } + + private boolean isExternalUserId(String userId) { + return userId != null && (userId.startsWith("wm") || userId.startsWith("wo")); } /** @@ -284,7 +392,7 @@ public class ArchiveMessageController { vo.setDecryptStatus(m.getDecryptStatus()); vo.setCreatedAt(m.getCreatedAt() != null ? m.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null); if (m.getMsgtime() != null) { - vo.setMsgTimeStr(LocalDateTime.ofInstant(Instant.ofEpochMilli(m.getMsgtime()), ZoneId.systemDefault()) + vo.setMsgTimeStr(LocalDateTime.ofInstant(Instant.ofEpochMilli(m.getMsgtime()), ZoneId.of("Asia/Shanghai")) .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); } return vo; diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/DashboardController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/DashboardController.java index 8d68b99..ff713d7 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/DashboardController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/DashboardController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; @@ -52,7 +53,7 @@ public class DashboardController { DateTimeFormatter fmt = DateTimeFormatter.ofPattern("MM-dd"); for (int i = days - 1; i >= 0; i--) { - LocalDate date = LocalDate.now().minusDays(i); + LocalDate date = LocalDate.now(ZoneId.of("Asia/Shanghai")).minusDays(i); LocalDateTime start = date.atStartOfDay(); LocalDateTime end = date.plusDays(1).atStartOfDay(); diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveContactService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveContactService.java index e15cc72..cd7da31 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveContactService.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveContactService.java @@ -268,7 +268,8 @@ public class ArchiveContactService { if (gender != null) customer.setGender(gender == 1 ? "MALE" : gender == 2 ? "FEMALE" : null); if (unionid != null) customer.setUnionid(unionid); if (addTime != null) { - customer.setAddTime(new java.sql.Timestamp(addTime).toLocalDateTime()); + customer.setAddTime(java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(addTime), java.time.ZoneId.of("Asia/Shanghai"))); } customerMapper.updateById(customer); log.info("补充客户资料成功: customerId={}, name={}", customerId, name); diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java index a86b8a2..ad96f4c 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java @@ -17,6 +17,8 @@ import javax.annotation.PreDestroy; import java.io.File; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -118,7 +120,7 @@ public class ArchivePullService { Map result = JsonUtils.fromJsonMap(jsonData); if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) { - log.error("拉取存档返回错误: {}", jsonData); + log.error("拉取存档返回错误: errcode={}, errmsg={}, json前200字={}", result != null ? result.get("errcode") : "null", result != null ? result.get("errmsg") : "null", jsonData.length() > 200 ? jsonData.substring(0, 200) : jsonData); break; } @@ -127,21 +129,25 @@ public class ArchivePullService { log.info("已无更多消息, seq={}", currentSeq); break; } + log.info("拉取到消息条数: seq={}, count={}", currentSeq, chatDataList.size()); + // 批量解密(一次子进程解密所有消息,避免重复启动 JVM) + List batchMessages = batchDecryptAndParse(chatDataList); + if (batchMessages != null && !batchMessages.isEmpty()) { + messages.addAll(batchMessages); + totalParsed += batchMessages.size(); + } + totalFetched += chatDataList.size(); + + // 计算 maxSeq long maxSeq = currentSeq; for (Map chatData : chatDataList) { try { - Long msgSeq = ((Number) chatData.get("seq")).longValue(); + Object seqObj = chatData.get("seq"); + Long msgSeq = seqObj instanceof Number ? ((Number) seqObj).longValue() : Long.parseLong(String.valueOf(seqObj)); if (msgSeq > maxSeq) maxSeq = msgSeq; - totalFetched++; - - ArchiveMessage message = parseAndDecrypt(chatData); - if (message != null) { - messages.add(message); - totalParsed++; - } } catch (Exception e) { - log.error("解析单条消息失败: {}", e.getMessage(), e); + log.error("计算seq失败: {}", e.getMessage()); } } @@ -168,25 +174,102 @@ public class ArchivePullService { Long msgSeq = ((Number) chatData.get("seq")).longValue(); Object pubKeyVer = chatData.get("publickey_ver"); - // publickey_ver 与当前私钥版本不匹配时跳过(企微后台可能更换过公钥) - // 当前私钥对应版本3,历史消息可能是版本2加密的 log.debug("chatData: seq={}, publickey_ver={}", msgSeq, pubKeyVer); - // 主服务中 RSA 解密(不涉及 JNI,安全) String encryptKey = rsaDecryptUtil.decrypt(encryptRandomKey, rsaPrivateKey); if (encryptKey == null) { log.debug("解密encrypt_random_key失败, seq={}, 可能是公钥版本不匹配", msgSeq); return null; } - // Worker 2: 解密单条消息内容(隔离崩溃风险) String decryptedMsg = callWorker("decrypt", corpId, secret, sdkPath, encryptKey, encryptChatMsg); if (decryptedMsg == null) { log.error("解密消息内容失败(worker崩溃或超时), seq={}", msgSeq); return null; } - Map msgMap = JsonUtils.fromJsonMap(decryptedMsg); + return buildMessageFromJson(chatData, decryptedMsg, msgSeq); + } + + /** + * 批量解密:一次子进程解密所有消息,避免重复启动 JVM + */ + private List batchDecryptAndParse(List> chatDataList) { + List> inputs = new ArrayList<>(); + Map> seqToChatData = new HashMap<>(); + + for (Map chatData : chatDataList) { + Object seqObj = chatData.get("seq"); + Long msgSeq = seqObj instanceof Number ? ((Number) seqObj).longValue() : Long.parseLong(String.valueOf(seqObj)); + + String encryptRandomKey = (String) chatData.get("encrypt_random_key"); + String encryptChatMsg = (String) chatData.get("encrypt_chat_msg"); + + String encryptKey = rsaDecryptUtil.decrypt(encryptRandomKey, rsaPrivateKey); + if (encryptKey == null) { + log.debug("批量解密: RSA解密失败, seq={}", msgSeq); + continue; + } + + Map input = new HashMap<>(); + input.put("seq", msgSeq); + input.put("encryptKey", encryptKey); + input.put("encryptMsg", encryptChatMsg); + inputs.add(input); + seqToChatData.put(msgSeq, chatData); + } + + if (inputs.isEmpty()) { + return Collections.emptyList(); + } + + String json = JsonUtils.toJson(inputs); + String result = callWorkerWithStdin(new String[]{"batchdecrypt", corpId, secret, sdkPath}, json); + if (result == null) { + log.error("批量解密返回null, count={}", inputs.size()); + return Collections.emptyList(); + } + + List> outputs; + try { + @SuppressWarnings("unchecked") + List> temp = (List>) (List) JsonUtils.fromJsonList(result, Map.class); + outputs = temp; + } catch (Exception e) { + log.error("批量解密结果解析失败: {}", e.getMessage()); + return Collections.emptyList(); + } + if (outputs == null) { + return Collections.emptyList(); + } + + List messages = new ArrayList<>(); + for (Map output : outputs) { + Object seqObj = output.get("seq"); + Long msgSeq = seqObj instanceof Number ? ((Number) seqObj).longValue() : Long.parseLong(String.valueOf(seqObj)); + Object errcodeObj = output.get("errcode"); + int errcode = errcodeObj instanceof Number ? ((Number) errcodeObj).intValue() : Integer.parseInt(String.valueOf(errcodeObj)); + + if (errcode != 0) { + log.error("批量解密单条失败: seq={}, errcode={}", msgSeq, errcode); + continue; + } + + String decryptedJson = (String) output.get("data"); + Map chatData = seqToChatData.get(msgSeq); + if (chatData == null || decryptedJson == null) continue; + + ArchiveMessage message = buildMessageFromJson(chatData, decryptedJson, msgSeq); + if (message != null) { + messages.add(message); + } + } + + return messages; + } + + private ArchiveMessage buildMessageFromJson(Map chatData, String decryptedJson, Long msgSeq) { + Map msgMap = JsonUtils.fromJsonMap(decryptedJson); if (msgMap == null) { return null; } @@ -199,7 +282,6 @@ public class ArchivePullService { message.setFromUser((String) msgMap.get("from")); message.setFromRole(detectRole((String) msgMap.get("from"))); - // tolist 可能是字符串或数组 Object tolistObj = msgMap.get("tolist"); if (tolistObj instanceof java.util.List) { message.setToUser(JsonUtils.toJson(tolistObj)); @@ -214,8 +296,6 @@ public class ArchivePullService { message.setMsgtime(((Number) msgTimeObj).longValue()); } - // 企微消息结构: { msgtype: "text", text: { content: "..." } } - // 根据 msgtype 从对应子对象提取内容 String msgtype = message.getMsgtype(); Object typeContent = msgMap.get(msgtype); if (typeContent instanceof Map) { @@ -266,6 +346,10 @@ public class ArchivePullService { private String callWorker(String... workerArgs) { + return callWorkerWithStdin(workerArgs, null); + } + + private String callWorkerWithStdin(String[] workerArgs, String stdinData) { List cmd = new ArrayList<>(); cmd.add("java"); cmd.add("-Djava.library.path=" + sdkPath); @@ -274,26 +358,48 @@ public class ArchivePullService { cmd.add("worker"); cmd.addAll(java.util.Arrays.asList(workerArgs)); + Process process = null; + java.util.concurrent.ExecutorService readerExecutor = null; try { ProcessBuilder pb = new ProcessBuilder(cmd); pb.redirectErrorStream(true); - Process process = pb.start(); + process = pb.start(); - String output; - try (java.io.BufferedReader reader = new java.io.BufferedReader( - new java.io.InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line); + // 如果有 stdin 数据,写入子进程标准输入 + if (stdinData != null && !stdinData.isEmpty()) { + try (java.io.OutputStream os = process.getOutputStream()) { + os.write(stdinData.getBytes(StandardCharsets.UTF_8)); + os.flush(); } - output = sb.toString(); } - boolean finished = process.waitFor(30, TimeUnit.SECONDS); + final Process p = process; + readerExecutor = java.util.concurrent.Executors.newSingleThreadExecutor(); + java.util.concurrent.Future future = readerExecutor.submit(() -> { + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } + }); + + String output; + try { + output = future.get(30, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + future.cancel(true); + log.error("Worker 读取超时: {}", java.util.Arrays.toString(workerArgs)); + return null; + } + + boolean finished = process.waitFor(5, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); - log.error("Worker 超时: {}", java.util.Arrays.toString(workerArgs)); + log.error("Worker 进程未在5秒内退出,已强制终止: {}", java.util.Arrays.toString(workerArgs)); return null; } @@ -303,10 +409,18 @@ public class ArchivePullService { return null; } + log.info("Worker 执行成功: {}, output长度={}", java.util.Arrays.toString(workerArgs), output != null ? output.length() : 0); return output; } catch (Exception e) { log.error("Worker 执行失败: {}", java.util.Arrays.toString(workerArgs), e); return null; + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + if (readerExecutor != null) { + readerExecutor.shutdownNow(); + } } } @@ -352,6 +466,8 @@ public class ArchivePullService { } public void saveAndNotify(List messages) { + log.info("开始保存消息, 共{}条", messages.size()); + int saved = 0; for (ArchiveMessage message : messages) { try { int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId()); @@ -370,6 +486,7 @@ public class ArchivePullService { } archiveMessageMapper.insert(message); + saved++; // 提取并维护联系人信息 archiveContactService.processMessage(message); @@ -390,17 +507,25 @@ public class ArchivePullService { ); } catch (Exception e) { - log.error("保存消息失败: msgid={}, error={}", message.getMsgid(), e.getMessage()); + log.error("保存消息失败: msgid={}, error={}", message.getMsgid(), e.getMessage(), e); } } + log.info("保存消息完成, 成功{}条/共{}条", saved, messages.size()); } public long getLastSeq() { Object seq = redisTemplate.opsForValue().get(LAST_SEQ_KEY + corpId); - if (seq != null) { - return ((Number) seq).longValue(); - } Long maxSeq = archiveMessageMapper.selectMaxSeq(corpId); + if (seq != null) { + long redisSeq = ((Number) seq).longValue(); + // 防御:如果 Redis 中的 seq 小于数据库最大值,使用数据库最大值 + if (maxSeq != null && redisSeq < maxSeq) { + log.warn("Redis seq({}) 小于数据库最大 seq({}),使用数据库最大值", redisSeq, maxSeq); + redisTemplate.opsForValue().set(LAST_SEQ_KEY + corpId, maxSeq, 7, TimeUnit.DAYS); + return maxSeq; + } + return redisSeq; + } return maxSeq != null ? maxSeq : 0L; } @@ -446,9 +571,8 @@ public class ArchivePullService { long startSeq = minSeq - 1; log.info("开始修复历史数据,起始 seq={}", startSeq); - // 重置 Redis 中的 last_seq - redisTemplate.opsForValue().set(LAST_SEQ_KEY + corpId, startSeq, 7, TimeUnit.DAYS); - + // 修复任务使用自己的 currentSeq,不重置 Redis 中的 last_seq + // 避免影响正常的增量拉取 int totalFetched = 0; int totalUpdated = 0; int totalInserted = 0; diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java index ca33587..04bf201 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java @@ -18,10 +18,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import java.text.SimpleDateFormat; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; @@ -44,7 +44,8 @@ public class ArchiveSessionService { @Autowired private ArchiveMessageMapper archiveMessageMapper; - private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + private static final java.time.format.DateTimeFormatter TIME_FORMATTER = + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); /** * 员工列表 @@ -349,7 +350,8 @@ public class ArchiveSessionService { private String formatTime(Long msgTime) { if (msgTime == null) return "-"; try { - return TIME_FORMAT.format(new Date(msgTime)); + return LocalDateTime.ofInstant(Instant.ofEpochMilli(msgTime), ZoneId.of("Asia/Shanghai")) + .format(TIME_FORMATTER); } catch (Exception e) { return String.valueOf(msgTime); } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/vo/ArchiveMessageVO.java b/backend/archive-service/src/main/java/com/artedu/archive/vo/ArchiveMessageVO.java index 923760f..3eff832 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/vo/ArchiveMessageVO.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/vo/ArchiveMessageVO.java @@ -17,6 +17,7 @@ public class ArchiveMessageVO { private String fromUserName; private String fromRole; private String toUser; + private String toUserName; private String tolist; private String roomid; private String msgtype; diff --git a/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java b/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java index 14387e2..d78df65 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java @@ -17,7 +17,7 @@ public class SdkWorker { public static void run(String[] args) throws Exception { if (args.length < 1) { - System.err.println("Usage: worker ..."); + System.err.println("Usage: worker ..."); System.exit(1); } String cmd = args[0]; @@ -25,6 +25,8 @@ public class SdkWorker { runPull(args); } else if ("decrypt".equals(cmd)) { runDecrypt(args); + } else if ("batchdecrypt".equals(cmd)) { + runBatchDecrypt(args); } else if ("media".equals(cmd)) { runGetMediaData(args); } else { @@ -89,6 +91,67 @@ public class SdkWorker { } } + private static void runBatchDecrypt(String[] args) throws Exception { + if (args.length < 4) { + System.err.println("Usage: worker batchdecrypt "); + System.exit(1); + } + String corpId = args[1]; + String secret = args[2]; + String sdkPath = args[3]; + + // 从 stdin 读取 JSON 输入,避免命令行参数过长(error=7 Argument list too long) + StringBuilder sb = new StringBuilder(); + byte[] buffer = new byte[8192]; + int read; + while ((read = System.in.read(buffer)) != -1) { + sb.append(new String(buffer, 0, read, java.nio.charset.StandardCharsets.UTF_8)); + } + String json = sb.toString().trim(); + if (json.isEmpty()) { + System.err.println("ERROR batch input is empty"); + System.exit(1); + } + + setupLibraryPath(sdkPath); + long sdk = initSdk(corpId, secret); + + @SuppressWarnings("unchecked") + java.util.List> inputs = (java.util.List>) (java.util.List) JsonUtils.fromJsonList(json, java.util.Map.class); + java.util.List> outputs = new java.util.ArrayList<>(); + + for (java.util.Map input : inputs) { + String encryptKey = (String) input.get("encryptKey"); + String encryptMsg = (String) input.get("encryptMsg"); + Object seqObj = input.get("seq"); + long seq = seqObj instanceof Number ? ((Number) seqObj).longValue() : Long.parseLong(String.valueOf(seqObj)); + + long slice = Finance.NewSlice(); + try { + int ret = Finance.DecryptData(sdk, encryptKey, encryptMsg, slice); + if (ret != 0) { + java.util.Map err = new java.util.HashMap<>(); + err.put("seq", seq); + err.put("errcode", ret); + err.put("errmsg", "DecryptData failed"); + outputs.add(err); + continue; + } + String decryptedJson = Finance.GetContentFromSlice(slice); + java.util.Map ok = new java.util.HashMap<>(); + ok.put("seq", seq); + ok.put("errcode", 0); + ok.put("data", decryptedJson); + outputs.add(ok); + } finally { + Finance.FreeSlice(slice); + } + } + + Finance.DestroySdk(sdk); + System.out.println(JsonUtils.toJson(outputs)); + } + private static void runGetMediaData(String[] args) throws Exception { if (args.length < 6) { System.err.println("Usage: worker media "); diff --git a/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar b/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar index 5dd9ff6..e51bd63 100644 Binary files a/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar and b/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar.original b/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar.original index 7c15da8..e6c33ff 100644 Binary files a/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar.original and b/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveCallbackController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveCallbackController.class index b023be2..37e1454 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveCallbackController.class and b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveCallbackController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController$ArchiveStatsVO.class b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController$ArchiveStatsVO.class index a261ff0..07870c5 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController$ArchiveStatsVO.class and b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController$ArchiveStatsVO.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController.class index c2de508..0434ea6 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController.class and b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController$TrendVO.class b/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController$TrendVO.class index 10e38c1..48e782c 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController$TrendVO.class and b/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController$TrendVO.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController.class index c7b74fb..04997e4 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController.class and b/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveContactService.class b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveContactService.class index 27ac093..5581ac8 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveContactService.class and b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveContactService.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/ArchivePullService.class b/backend/archive-service/target/classes/com/artedu/archive/service/ArchivePullService.class index 37cb283..0b4f9a1 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/service/ArchivePullService.class and b/backend/archive-service/target/classes/com/artedu/archive/service/ArchivePullService.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveSessionService.class b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveSessionService.class index eb6d093..6ba5460 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveSessionService.class and b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveSessionService.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/vo/ArchiveMessageVO.class b/backend/archive-service/target/classes/com/artedu/archive/vo/ArchiveMessageVO.class index bb7385c..3549634 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/vo/ArchiveMessageVO.class and b/backend/archive-service/target/classes/com/artedu/archive/vo/ArchiveMessageVO.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/worker/SdkWorker.class b/backend/archive-service/target/classes/com/artedu/archive/worker/SdkWorker.class index b8aa854..4478a22 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/worker/SdkWorker.class and b/backend/archive-service/target/classes/com/artedu/archive/worker/SdkWorker.class differ diff --git a/backend/common/target/common-1.0.0-SNAPSHOT.jar b/backend/common/target/common-1.0.0-SNAPSHOT.jar index 31d1237..d73a25f 100644 Binary files a/backend/common/target/common-1.0.0-SNAPSHOT.jar and b/backend/common/target/common-1.0.0-SNAPSHOT.jar differ diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java b/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java index f824b96..b8ee360 100644 --- a/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java @@ -1,11 +1,11 @@ package com.artedu.conversation.controller; import com.artedu.common.result.Result; +import com.artedu.conversation.entity.ArchiveMessage; import com.artedu.conversation.entity.Conversation; import com.artedu.conversation.entity.ConversationTurn; +import com.artedu.conversation.mapper.ArchiveMessageMapper; import com.artedu.conversation.service.ConversationManager; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; @@ -25,6 +25,9 @@ public class ConversationController { @Autowired private ConversationManager conversationManager; + @Autowired + private ArchiveMessageMapper archiveMessageMapper; + @GetMapping("/{sessionId}/context") public Result> getContext(@NotBlank(message = "sessionId不能为空") @PathVariable("sessionId") String sessionId) { String contextStr = conversationManager.getContextString(sessionId); @@ -59,6 +62,22 @@ public class ConversationController { return Result.success(list); } + @GetMapping("/last-message") + public Result> getLastMessage( + @NotBlank(message = "customerId不能为空") @RequestParam("customerId") String customerId, + @NotBlank(message = "corpId不能为空") @RequestParam("corpId") String corpId) { + + ArchiveMessage message = archiveMessageMapper.selectLastMessage(customerId, corpId); + Map result = new HashMap<>(); + if (message != null) { + result.put("content", message.getContent()); + result.put("msgTime", message.getMsgtime()); + result.put("msgType", message.getMsgtype()); + result.put("fromUser", message.getFromUser()); + } + return Result.success(result); + } + @PutMapping("/{sessionId}/status") public Result updateStatus( @NotBlank(message = "sessionId不能为空") @PathVariable("sessionId") String sessionId, diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/entity/ArchiveMessage.java b/backend/conversation-service/src/main/java/com/artedu/conversation/entity/ArchiveMessage.java new file mode 100644 index 0000000..6205462 --- /dev/null +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/entity/ArchiveMessage.java @@ -0,0 +1,32 @@ +package com.artedu.conversation.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("archive_messages") +public class ArchiveMessage { + @TableId(type = IdType.AUTO) + private Long id; + private String msgid; + private Long seq; + private String corpId; + private String action; + private String fromUser; + private String fromRole; + private String toUser; + private String tolist; + private String roomid; + private String msgtype; + private Long msgtime; + private String content; + private String mediaData; + private Integer decryptStatus; + private String sessionId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/mapper/ArchiveMessageMapper.java b/backend/conversation-service/src/main/java/com/artedu/conversation/mapper/ArchiveMessageMapper.java new file mode 100644 index 0000000..e192ba3 --- /dev/null +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/mapper/ArchiveMessageMapper.java @@ -0,0 +1,19 @@ +package com.artedu.conversation.mapper; + +import com.artedu.conversation.entity.ArchiveMessage; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ArchiveMessageMapper extends BaseMapper { + + @Select("SELECT * FROM archive_messages " + + "WHERE (from_user = #{userId} OR to_user LIKE CONCAT('%', #{userId}, '%')) " + + "AND corp_id = #{corpId} " + + "AND content IS NOT NULL AND content != '' " + + "ORDER BY msgtime DESC LIMIT 1") + ArchiveMessage selectLastMessage(@Param("userId") String userId, + @Param("corpId") String corpId); +} diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/controller/ConversationController.class b/backend/conversation-service/target/classes/com/artedu/conversation/controller/ConversationController.class index 1e9bcb6..322fd07 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/controller/ConversationController.class and b/backend/conversation-service/target/classes/com/artedu/conversation/controller/ConversationController.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/entity/ArchiveMessage.class b/backend/conversation-service/target/classes/com/artedu/conversation/entity/ArchiveMessage.class new file mode 100644 index 0000000..c566e27 Binary files /dev/null and b/backend/conversation-service/target/classes/com/artedu/conversation/entity/ArchiveMessage.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ArchiveMessageMapper.class b/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ArchiveMessageMapper.class new file mode 100644 index 0000000..1876444 Binary files /dev/null and b/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ArchiveMessageMapper.class differ diff --git a/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar b/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar index d8daf06..0fce136 100644 Binary files a/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar and b/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar.original b/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar.original index 221748d..7959fc8 100644 Binary files a/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar.original and b/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/conversation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/backend/conversation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 0f487f0..7d7e578 100644 --- a/backend/conversation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/backend/conversation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -3,7 +3,9 @@ com\artedu\conversation\ConversationServiceApplication.class com\artedu\conversation\mapper\ConversationTurnMapper.class com\artedu\conversation\entity\Conversation.class com\artedu\conversation\entity\ConversationTurn.class +com\artedu\conversation\mapper\ArchiveMessageMapper.class com\artedu\conversation\service\ConversationManager$ContextMessage.class +com\artedu\conversation\entity\ArchiveMessage.class com\artedu\conversation\service\ConversationManager.class com\artedu\conversation\service\ConversationManager$ArchiveMessageEvent.class com\artedu\conversation\controller\ConversationController.class diff --git a/backend/conversation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/backend/conversation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index 267a809..8a617ac 100644 --- a/backend/conversation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/backend/conversation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -1,7 +1,9 @@ D:\www\agent_9art\backend\conversation-service\src\main\java\com\artedu\conversation\controller\ConversationController.java D:\www\agent_9art\backend\conversation-service\src\main\java\com\artedu\conversation\ConversationServiceApplication.java +D:\www\agent_9art\backend\conversation-service\src\main\java\com\artedu\conversation\entity\ArchiveMessage.java D:\www\agent_9art\backend\conversation-service\src\main\java\com\artedu\conversation\entity\Conversation.java D:\www\agent_9art\backend\conversation-service\src\main\java\com\artedu\conversation\entity\ConversationTurn.java +D:\www\agent_9art\backend\conversation-service\src\main\java\com\artedu\conversation\mapper\ArchiveMessageMapper.java D:\www\agent_9art\backend\conversation-service\src\main\java\com\artedu\conversation\mapper\ConversationMapper.java D:\www\agent_9art\backend\conversation-service\src\main\java\com\artedu\conversation\mapper\ConversationTurnMapper.java D:\www\agent_9art\backend\conversation-service\src\main\java\com\artedu\conversation\service\ConversationManager.java diff --git a/frontend/admin/src/components/ChatTimeline.tsx b/frontend/admin/src/components/ChatTimeline.tsx index 2697bb2..f1b9952 100644 --- a/frontend/admin/src/components/ChatTimeline.tsx +++ b/frontend/admin/src/components/ChatTimeline.tsx @@ -246,6 +246,7 @@ export default function ChatTimeline({ sessionId, staffName = '员工', customer {renderContent(msg)}
+ {staffSide ? staffName : customerName} {msgTypeMap[msg.msgtype]?.label || msg.msgtype} diff --git a/frontend/admin/src/pages/MessageSearch.tsx b/frontend/admin/src/pages/MessageSearch.tsx index d148f2f..eb52a43 100644 --- a/frontend/admin/src/pages/MessageSearch.tsx +++ b/frontend/admin/src/pages/MessageSearch.tsx @@ -9,11 +9,14 @@ interface ArchiveMessage { id: number msgid: string fromUser: string + fromUserName?: string fromRole: string toUser: string + toUserName?: string msgtype: string content: string msgtime: number + msgTimeStr?: string sessionId: string } @@ -40,6 +43,8 @@ export default function MessageSearch() { const [searched, setSearched] = useState(false) const [chatVisible, setChatVisible] = useState(false) const [chatSessionId, setChatSessionId] = useState('') + const [chatStaffName, setChatStaffName] = useState('') + const [chatCustomerName, setChatCustomerName] = useState('') const loadMessages = async (page = 1, pageSize = 20, searchParams?: any) => { setLoading(true) @@ -97,7 +102,8 @@ export default function MessageSearch() { return dayjs(ts).format('YYYY-MM-DD HH:mm:ss') } - const getSenderLabel = (role: string, userId: string) => { + const getSenderLabel = (role: string, userId: string, userName?: string) => { + if (userName && userName !== userId) return userName if (role === 'INTERNAL') return `员工:${userId}` if (role === 'EXTERNAL') return `客户:${userId}` return userId @@ -129,7 +135,7 @@ export default function MessageSearch() { width: 150, render: (fromUser: string, record: ArchiveMessage) => ( - {getSenderLabel(record.fromRole, fromUser)} + {getSenderLabel(record.fromRole, fromUser, record.fromUserName)} ), }, @@ -138,14 +144,18 @@ export default function MessageSearch() { dataIndex: 'toUser', key: 'toUser', width: 150, - render: (toUser: string) => toUser || '-', + render: (toUser: string, record: ArchiveMessage) => ( + record.toUserName || toUser || '-' + ), }, { title: '发送时间', dataIndex: 'msgtime', key: 'msgtime', width: 170, - render: (msgtime: number) => formatTime(msgtime), + render: (msgtime: number, record: ArchiveMessage) => ( + record.msgTimeStr || formatTime(msgtime) + ), }, { title: '操作', @@ -157,6 +167,14 @@ export default function MessageSearch() { icon={} onClick={() => { setChatSessionId(record.sessionId) + // 根据 fromRole 判断谁是员工、谁是客户,避免昵称对调 + if (record.fromRole === 'INTERNAL') { + setChatStaffName(record.fromUserName || record.fromUser) + setChatCustomerName(record.toUserName || record.toUser) + } else { + setChatStaffName(record.toUserName || record.toUser) + setChatCustomerName(record.fromUserName || record.fromUser) + } setChatVisible(true) }} > @@ -194,12 +212,12 @@ export default function MessageSearch() { - + - + @@ -251,7 +269,7 @@ export default function MessageSearch() { footer={null} destroyOnClose > - +
) diff --git a/frontend/sidebar/src/components/ScriptRecommend.tsx b/frontend/sidebar/src/components/ScriptRecommend.tsx index b55d195..0ac0536 100644 --- a/frontend/sidebar/src/components/ScriptRecommend.tsx +++ b/frontend/sidebar/src/components/ScriptRecommend.tsx @@ -7,12 +7,6 @@ interface Props { customerId?: string } -interface ConversationTurn { - turnNumber: number - studentContent: string - seatContent: string -} - export default function ScriptRecommend({ userInfo, customerId: propCustomerId }: Props) { const [scripts, setScripts] = useState([]) const [loading, setLoading] = useState(false) @@ -26,30 +20,14 @@ export default function ScriptRecommend({ userInfo, customerId: propCustomerId } const staffId = userInfo?.userId || 'staff_001' const corpId = userInfo?.corpId || 'wwd483c2fba24ae30a' - // 动态获取最后一条学员消息 + // 动态获取最后一条学员消息(直接从 archive_messages 表查询,延迟更低) const fetchLastCustomerMessage = async () => { if (!customerId || customerId === 'wx_001') return try { - const res = await fetch(`/api/v1/conversations?customerId=${customerId}&corpId=${corpId}`) + const res = await fetch(`/api/v1/conversations/last-message?customerId=${customerId}&corpId=${corpId}`) const data = await res.json() - if (data.code === 0 && data.data && data.data.length > 0) { - // 取最新的会话 - const sessions = data.data as Array<{ sessionId: string; startTime: string }> - const latestSession = sessions.sort( - (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() - )[0] - const turnsRes = await fetch(`/api/v1/conversations/${latestSession.sessionId}/turns`) - const turnsData = await turnsRes.json() - if (turnsData.code === 0 && turnsData.data && turnsData.data.length > 0) { - const turns = turnsData.data as ConversationTurn[] - // 取最后一条有内容的学员消息 - for (let i = turns.length - 1; i >= 0; i--) { - if (turns[i].studentContent?.trim()) { - setCustomerMsg(turns[i].studentContent.trim()) - break - } - } - } + if (data.code === 0 && data.data && data.data.content) { + setCustomerMsg(data.data.content) } } catch (e) { console.error('获取最后一条学员消息失败:', e)