feat: 完成企业微信会话存档全链路功能迭代
1. 后端服务: - 新增ArchiveMessage实体与Mapper,实现消息持久化 - 重构拉取解密逻辑,支持批量解密提升性能 - 补充员工/客户昵称自动填充与实时同步 - 修复时区问题,统一使用Asia/Shanghai时区 - 新增回调线程池,优化异步处理能力 - 新增批量解密worker命令,解决长参数溢出问题 - 优化日志打印与异常捕获逻辑 2. 前端页面: - 优化聊天时间线展示,显示发送者昵称 - 重构话术推荐接口调用逻辑,降低接口延迟 - 优化消息搜索页面,支持昵称搜索与展示
This commit is contained in:
parent
4d8bc95063
commit
60b6370cd1
@ -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");
|
||||
|
||||
@ -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<String> matchedIds = new java.util.HashSet<>();
|
||||
matchedIds.add(fromUser);
|
||||
|
||||
// 员工昵称模糊匹配
|
||||
LambdaQueryWrapper<Staff> staffWrapper = new LambdaQueryWrapper<>();
|
||||
staffWrapper.eq(Staff::getCorpId, corpId).like(Staff::getName, fromUser);
|
||||
List<Staff> staffs = staffMapper.selectList(staffWrapper);
|
||||
if (staffs != null) {
|
||||
for (Staff s : staffs) {
|
||||
matchedIds.add(s.getStaffId());
|
||||
}
|
||||
}
|
||||
|
||||
// 客户昵称模糊匹配
|
||||
LambdaQueryWrapper<Customer> customerWrapper = new LambdaQueryWrapper<>();
|
||||
customerWrapper.eq(Customer::getCorpId, corpId).like(Customer::getName, fromUser);
|
||||
List<Customer> 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<ArchiveMessageVO> list) {
|
||||
if (list == null || list.isEmpty()) return;
|
||||
|
||||
Set<String> userIds = list.stream()
|
||||
.map(ArchiveMessageVO::getFromUser)
|
||||
.filter(id -> id != null && !id.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
// 收集所有需要查询昵称的userId(发送者 + 接收者)
|
||||
Set<String> 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<String> 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<Staff> 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<String, Object> detail = weComApiClient.getExternalContact(uid);
|
||||
if (detail != null && !detail.isEmpty()) {
|
||||
Map<String, Object> contact = (Map<String, Object>) 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<Customer>()
|
||||
.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<String> 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;
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<String, Object> 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<ArchiveMessage> batchMessages = batchDecryptAndParse(chatDataList);
|
||||
if (batchMessages != null && !batchMessages.isEmpty()) {
|
||||
messages.addAll(batchMessages);
|
||||
totalParsed += batchMessages.size();
|
||||
}
|
||||
totalFetched += chatDataList.size();
|
||||
|
||||
// 计算 maxSeq
|
||||
long maxSeq = currentSeq;
|
||||
for (Map<String, Object> 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<String, Object> msgMap = JsonUtils.fromJsonMap(decryptedMsg);
|
||||
return buildMessageFromJson(chatData, decryptedMsg, msgSeq);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量解密:一次子进程解密所有消息,避免重复启动 JVM
|
||||
*/
|
||||
private List<ArchiveMessage> batchDecryptAndParse(List<Map<String, Object>> chatDataList) {
|
||||
List<Map<String, Object>> inputs = new ArrayList<>();
|
||||
Map<Long, Map<String, Object>> seqToChatData = new HashMap<>();
|
||||
|
||||
for (Map<String, Object> 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<String, Object> 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<Map<String, Object>> outputs;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> temp = (List<Map<String, Object>>) (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<ArchiveMessage> messages = new ArrayList<>();
|
||||
for (Map<String, Object> 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<String, Object> 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<String, Object> chatData, String decryptedJson, Long msgSeq) {
|
||||
Map<String, Object> 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<String> 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<String> 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<ArchiveMessage> 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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -17,7 +17,7 @@ public class SdkWorker {
|
||||
|
||||
public static void run(String[] args) throws Exception {
|
||||
if (args.length < 1) {
|
||||
System.err.println("Usage: worker <pull|decrypt|media> ...");
|
||||
System.err.println("Usage: worker <pull|decrypt|batchdecrypt|media> ...");
|
||||
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 <corpId> <secret> <sdkPath>");
|
||||
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<java.util.Map<String, Object>> inputs = (java.util.List<java.util.Map<String, Object>>) (java.util.List<?>) JsonUtils.fromJsonList(json, java.util.Map.class);
|
||||
java.util.List<java.util.Map<String, Object>> outputs = new java.util.ArrayList<>();
|
||||
|
||||
for (java.util.Map<String, Object> 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<String, Object> 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<String, Object> 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 <corpId> <secret> <sdkPath> <sdkfileid> <outputPath>");
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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<Map<String, Object>> 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<Map<String, Object>> getLastMessage(
|
||||
@NotBlank(message = "customerId不能为空") @RequestParam("customerId") String customerId,
|
||||
@NotBlank(message = "corpId不能为空") @RequestParam("corpId") String corpId) {
|
||||
|
||||
ArchiveMessage message = archiveMessageMapper.selectLastMessage(customerId, corpId);
|
||||
Map<String, Object> 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<String> updateStatus(
|
||||
@NotBlank(message = "sessionId不能为空") @PathVariable("sessionId") String sessionId,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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<ArchiveMessage> {
|
||||
|
||||
@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);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -246,6 +246,7 @@ export default function ChatTimeline({ sessionId, staffName = '员工', customer
|
||||
{renderContent(msg)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#aaa', marginTop: 4, textAlign: staffSide ? 'right' : 'left' }}>
|
||||
<span style={{ marginRight: 4, color: '#666' }}>{staffSide ? staffName : customerName}</span>
|
||||
<Tag style={{ fontSize: 10, padding: '0 4px', lineHeight: '16px' }}>
|
||||
{msgTypeMap[msg.msgtype]?.label || msg.msgtype}
|
||||
</Tag>
|
||||
|
||||
@ -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<string>('')
|
||||
const [chatStaffName, setChatStaffName] = useState<string>('')
|
||||
const [chatCustomerName, setChatCustomerName] = useState<string>('')
|
||||
|
||||
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) => (
|
||||
<Tag color={record.fromRole === 'INTERNAL' ? 'blue' : 'green'}>
|
||||
{getSenderLabel(record.fromRole, fromUser)}
|
||||
{getSenderLabel(record.fromRole, fromUser, record.fromUserName)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@ -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={<MessageOutlined />}
|
||||
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() {
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item name="fromUser" label="发送者">
|
||||
<Input placeholder="发送者ID" allowClear />
|
||||
<Input placeholder="发送者昵称/ID" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item name="toUser" label="接收者">
|
||||
<Input placeholder="接收者ID" allowClear />
|
||||
<Input placeholder="接收者昵称/ID" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
@ -251,7 +269,7 @@ export default function MessageSearch() {
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<ChatTimeline sessionId={chatSessionId} />
|
||||
<ChatTimeline sessionId={chatSessionId} staffName={chatStaffName} customerName={chatCustomerName} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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<Recommendation[]>([])
|
||||
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user