diff --git a/backend/archive-service/pom.xml b/backend/archive-service/pom.xml index 7202414..f21d90b 100644 --- a/backend/archive-service/pom.xml +++ b/backend/archive-service/pom.xml @@ -18,6 +18,7 @@ org.springframework.bootspring-boot-starter-testtest org.bouncycastlebcpkix-jdk18on1.78 com.alibabaeasyexcel3.3.4 + io.milvusmilvus-sdk-java2.4.10 diff --git a/backend/archive-service/src/main/java/com/artedu/archive/config/MilvusConfig.java b/backend/archive-service/src/main/java/com/artedu/archive/config/MilvusConfig.java new file mode 100644 index 0000000..9f6f6af --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/config/MilvusConfig.java @@ -0,0 +1,49 @@ +package com.artedu.archive.config; + +import io.milvus.client.MilvusServiceClient; +import io.milvus.param.ConnectParam; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PreDestroy; + +/** + * Milvus 客户端配置 + */ +@Slf4j +@Configuration +@EnableConfigurationProperties(MilvusProperties.class) +public class MilvusConfig { + + @Autowired + private MilvusProperties milvusProperties; + + private MilvusServiceClient milvusClient; + + @Bean + public MilvusServiceClient milvusServiceClient() { + ConnectParam.Builder builder = ConnectParam.newBuilder() + .withHost(milvusProperties.getHost()) + .withPort(milvusProperties.getPort()); + + if (milvusProperties.getUsername() != null && !milvusProperties.getUsername().isEmpty()) { + builder.withAuthorization(milvusProperties.getUsername(), milvusProperties.getPassword()); + } + + milvusClient = new MilvusServiceClient(builder.build()); + log.info("Milvus client initialized: {}:{}, collection={}", + milvusProperties.getHost(), milvusProperties.getPort(), milvusProperties.getCollection()); + return milvusClient; + } + + @PreDestroy + public void close() { + if (milvusClient != null) { + milvusClient.close(); + log.info("Milvus client closed"); + } + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/config/MilvusProperties.java b/backend/archive-service/src/main/java/com/artedu/archive/config/MilvusProperties.java new file mode 100644 index 0000000..338305a --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/config/MilvusProperties.java @@ -0,0 +1,18 @@ +package com.artedu.archive.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Milvus 连接配置 + */ +@Data +@ConfigurationProperties(prefix = "milvus") +public class MilvusProperties { + + private String host = "localhost"; + private Integer port = 19530; + private String collection = "message_summary"; + private String username = ""; + private String password = ""; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveContactController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveContactController.java index 1d870a6..73b133c 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveContactController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveContactController.java @@ -118,4 +118,19 @@ public class ArchiveContactController { int count = archiveContactService.fillAllCustomerDetails(); return Result.success("已开始批量补充客户资料,共 " + count + " 人,请稍后在客户列表查看结果"); } + + // ========== 统计校准 ========== + + @PostMapping("/customers/{customerId}/recalculate-stats") + public Result recalculateCustomerStats(@PathVariable String customerId) { + boolean ok = archiveContactService.recalculateCustomerStats(customerId); + return ok ? Result.success("客户统计校准完成") : Result.fail("客户不存在或校准失败"); + } + + @PostMapping("/customers/recalculate-all-stats") + public Result recalculateAllCustomerStats( + @RequestParam(required = false, defaultValue = "100") @Min(1) Integer pageSize) { + int count = archiveContactService.recalculateAllCustomerStats(pageSize); + return Result.success("已开始全量校准客户统计,预计处理 " + count + " 人"); + } } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java new file mode 100644 index 0000000..6bcecc7 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java @@ -0,0 +1,40 @@ +package com.artedu.archive.controller; + +import com.artedu.archive.service.DailySummaryService; +import com.artedu.archive.vo.DailySummaryVO; +import com.artedu.common.result.Result; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 每日总结接口 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/archive") +public class DailySummaryController { + + @Autowired + private DailySummaryService dailySummaryService; + + /** + * 查询客户指定日期的消息总结 + * + * @param customerId 客户 ID(企微外部联系人 userid) + * @param date 日期 yyyy-MM-dd,默认今天 + */ + @GetMapping("/daily-summary") + public Result> dailySummary( + @RequestParam String customerId, + @RequestParam(required = false) String date) { + + List list = dailySummaryService.queryDailySummary(customerId, date); + return Result.success(list); + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java index 058b39e..57939d6 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java @@ -78,6 +78,19 @@ public interface ArchiveMessageMapper extends BaseMapper { @Select("SELECT MAX(msgtime) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId})") Long selectLastMsgTimeByAnyUser(@Param("userId") String userId, @Param("corpId") String corpId); + @Select("SELECT MIN(msgtime) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId})") + Long selectFirstMsgTimeByAnyUser(@Param("userId") String userId, @Param("corpId") String corpId); + + @Select("SELECT COUNT(*) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId})") + Integer countMsgByAnyUser(@Param("userId") String userId, @Param("corpId") String corpId); + + @Select("SELECT DISTINCT u.staff_id FROM (" + + " SELECT from_user AS staff_id FROM archive_messages WHERE corp_id = #{corpId} AND from_role = 'INTERNAL' AND (to_user = #{customerId} OR tolist LIKE CONCAT('%', #{customerId}, '%'))" + + " UNION" + + " SELECT to_user AS staff_id FROM archive_messages WHERE corp_id = #{corpId} AND from_user = #{customerId} AND to_user IS NOT NULL AND to_user != ''" + + ") u WHERE u.staff_id IS NOT NULL AND u.staff_id != ''") + List selectDistinctStaffIdsByCustomer(@Param("customerId") String customerId, @Param("corpId") String corpId); + @Select("SELECT COUNT(DISTINCT from_user) FROM archive_messages " + "WHERE corp_id = #{corpId} AND from_role = 'EXTERNAL' " + "AND session_id IN (" + 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 cd7da31..151f92c 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 @@ -9,10 +9,13 @@ import com.artedu.archive.mapper.CustomerMapper; import com.artedu.archive.mapper.StaffMapper; import com.artedu.common.util.JsonUtils; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +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.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.time.Instant; @@ -492,4 +495,111 @@ public class ArchiveContactService { } return count; } + + /** + * 每天凌晨 00:05 清零客户今日消息数 + */ + @Scheduled(cron = "0 5 0 * * ?") + public void resetTodayMsgCount() { + try { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(Customer::getCorpId, corpId).set(Customer::getTodayMsgCount, 0); + int rows = customerMapper.update(null, wrapper); + log.info("清零客户今日消息数完成,共 {} 人", rows); + } catch (Exception e) { + log.error("清零客户今日消息数失败", e); + } + } + + /** + * 重新计算单个客户的统计数据 + */ + public boolean recalculateCustomerStats(String customerId) { + try { + Customer customer = customerMapper.selectByCustomerId(customerId, corpId); + if (customer == null) { + log.warn("客户不存在: {}", customerId); + return false; + } + + Integer totalCount = archiveMessageMapper.countMsgByAnyUser(customerId, corpId); + Integer todayCount = archiveMessageMapper.countTodayMsgByCustomer(customerId, corpId); + Long firstMsgTime = archiveMessageMapper.selectFirstMsgTimeByAnyUser(customerId, corpId); + Long lastMsgTime = archiveMessageMapper.selectLastMsgTimeByAnyUser(customerId, corpId); + List rawStaffIds = archiveMessageMapper.selectDistinctStaffIdsByCustomer(customerId, corpId); + + Set staffIdSet = new HashSet<>(); + if (rawStaffIds != null) { + for (String rawId : rawStaffIds) { + if (rawId == null || rawId.isEmpty()) { + continue; + } + // 兼容历史数据中 to_user 存了 JSON 数组的情况,如 ["DengHan"] + if (rawId.startsWith("[")) { + try { + List parsed = JsonUtils.fromJsonList(rawId, String.class); + if (parsed != null) { + staffIdSet.addAll(parsed.stream().filter(s -> s != null && !s.isEmpty()).collect(Collectors.toList())); + continue; + } + } catch (Exception e) { + log.debug("解析员工ID JSON 数组失败: {}", rawId); + } + staffIdSet.add(rawId.replaceAll("[\\[\\]\"]", "").trim()); + } else { + staffIdSet.add(rawId); + } + } + } + + customer.setMsgCount(totalCount != null ? totalCount : 0); + customer.setTodayMsgCount(todayCount != null ? todayCount : 0); + customer.setFirstMsgTime(firstMsgTime); + customer.setLastMsgTime(lastMsgTime); + customer.setStaffIds(JsonUtils.toJson(new ArrayList<>(staffIdSet))); + + customerMapper.updateById(customer); + log.info("重新计算客户统计完成: customerId={}, msgCount={}, todayMsgCount={}, staffCount={}", + customerId, customer.getMsgCount(), customer.getTodayMsgCount(), staffIdSet.size()); + return true; + } catch (Exception e) { + log.error("重新计算客户统计失败: customerId={}", customerId, e); + return false; + } + } + + /** + * 全量重新计算所有客户统计数据 + */ + public int recalculateAllCustomerStats(int pageSize) { + int total = 0; + int success = 0; + try { + long pages = 0; + long current = 1; + do { + Page page = customerMapper.selectPage( + new Page<>(current, pageSize), + new LambdaQueryWrapper().eq(Customer::getCorpId, corpId) + ); + List customers = page.getRecords(); + if (customers.isEmpty()) { + break; + } + pages = page.getPages(); + for (Customer customer : customers) { + total++; + if (recalculateCustomerStats(customer.getCustomerId())) { + success++; + } + } + current++; + } while (current <= pages); + + log.info("全量重新计算客户统计完成,共 {} 人,成功 {} 人", total, success); + } catch (Exception e) { + log.error("全量重新计算客户统计失败", e); + } + return success; + } } 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 ad96f4c..d13dc93 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 @@ -596,14 +596,19 @@ public class ArchivePullService { for (ArchiveMessage message : messages) { try { int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId()); + boolean isNew = false; if (count > 0) { int updated = archiveMessageMapper.updateByMsgId(message); if (updated > 0) { totalUpdated++; roundUpdated++; } } else { archiveMessageMapper.insert(message); totalInserted++; roundInserted++; + isNew = true; + } + // 只有新消息才更新联系人统计,避免历史修复重复累加 + if (isNew) { + archiveContactService.processMessage(message); } - archiveContactService.processMessage(message); archiveRiskService.processMessageRisk(message); redisTemplate.opsForValue().set( LAST_SEQ_KEY + corpId, 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 04bf201..a6ada85 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 @@ -96,23 +96,9 @@ public class ArchiveSessionService { public PageResult listStaffSessions(String staffId, int page, int size) { int offset = (page - 1) * size; List> rows = archiveMessageMapper.selectSessionsByUser(staffId, corpId, offset, size); - Long total = archiveMessageMapper.countSessionsByUser(staffId, corpId); - List list = new ArrayList<>(); - for (Map row : rows) { - SessionVO vo = new SessionVO(); - String sessionId = (String) row.get("session_id"); - vo.setSessionId(sessionId); - vo.setMsgCount(((Number) row.get("cnt")).intValue()); - Long lastTime = row.get("last_time") instanceof Number ? ((Number) row.get("last_time")).longValue() : null; - vo.setLastMsgTime(lastTime); - vo.setLastMsgTimeStr(formatTime(lastTime)); - - // 查询会话中的对方信息 - fillSessionParticipants(vo, sessionId); - list.add(vo); - } - return PageResult.of(page, size, total != null ? total : 0L, list); + List list = mergeSessions(rows); + return PageResult.of(page, size, (long) list.size(), list); } /** @@ -121,21 +107,71 @@ public class ArchiveSessionService { public PageResult listCustomerSessions(String customerId, int page, int size) { int offset = (page - 1) * size; List> rows = archiveMessageMapper.selectSessionsByUser(customerId, corpId, offset, size); - Long total = archiveMessageMapper.countSessionsByUser(customerId, corpId); - List list = new ArrayList<>(); + List list = mergeSessions(rows); + return PageResult.of(page, size, (long) list.size(), list); + } + + /** + * 合并同一对参与者的双向 session_id + */ + private List mergeSessions(List> rows) { + Map merged = new HashMap<>(); for (Map row : rows) { - SessionVO vo = new SessionVO(); String sessionId = (String) row.get("session_id"); - vo.setSessionId(sessionId); - vo.setMsgCount(((Number) row.get("cnt")).intValue()); + String canonicalId = normalizeSessionId(sessionId); + SessionVO vo = merged.computeIfAbsent(canonicalId, k -> { + SessionVO s = new SessionVO(); + s.setSessionId(canonicalId); + return s; + }); + + int cnt = ((Number) row.get("cnt")).intValue(); + vo.setMsgCount((vo.getMsgCount() != null ? vo.getMsgCount() : 0) + cnt); + Long lastTime = row.get("last_time") instanceof Number ? ((Number) row.get("last_time")).longValue() : null; - vo.setLastMsgTime(lastTime); - vo.setLastMsgTimeStr(formatTime(lastTime)); - fillSessionParticipants(vo, sessionId); - list.add(vo); + if (lastTime != null && (vo.getLastMsgTime() == null || lastTime > vo.getLastMsgTime())) { + vo.setLastMsgTime(lastTime); + vo.setLastMsgTimeStr(formatTime(lastTime)); + } } - return PageResult.of(page, size, total != null ? total : 0L, list); + + List list = new ArrayList<>(merged.values()); + for (SessionVO vo : list) { + fillSessionParticipants(vo, vo.getSessionId()); + } + + list.sort((a, b) -> Long.compare( + b.getLastMsgTime() != null ? b.getLastMsgTime() : 0L, + a.getLastMsgTime() != null ? a.getLastMsgTime() : 0L)); + return list; + } + + /** + * 规范化单聊 session_id:按参与者字典序排序 + */ + private String normalizeSessionId(String sessionId) { + if (sessionId == null || sessionId.isEmpty()) { + return sessionId; + } + int firstUnderscore = sessionId.indexOf('_'); + if (firstUnderscore < 0) { + return sessionId; + } + String corpPrefix = sessionId.substring(0, firstUnderscore); + String rest = sessionId.substring(firstUnderscore + 1); + // 群聊:corpId_roomId,不处理 + if (!rest.contains("_")) { + return sessionId; + } + String[] users = rest.split("_", 2); + if (users.length != 2) { + return sessionId; + } + if (users[0].compareTo(users[1]) > 0) { + return corpPrefix + "_" + users[1] + "_" + users[0]; + } + return sessionId; } /** diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java new file mode 100644 index 0000000..bdc6fc8 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java @@ -0,0 +1,224 @@ +package com.artedu.archive.service; + +import com.artedu.archive.config.MilvusProperties; +import com.artedu.archive.entity.Staff; +import com.artedu.archive.mapper.StaffMapper; +import com.artedu.archive.vo.DailySummaryVO; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import io.milvus.client.MilvusServiceClient; +import io.milvus.grpc.QueryResults; +import io.milvus.param.R; +import io.milvus.param.dml.QueryParam; +import io.milvus.response.FieldDataWrapper; +import io.milvus.response.QueryResultsWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 每日总结查询服务 + */ +@Slf4j +@Service +public class DailySummaryService { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final List OUTPUT_FIELDS = Arrays.asList( + "id", "msg_time", "msg_type", "from_user", "to_user", + "room_id", "message_context", "summary", "created_at" + ); + + @Value("${wecom.archive.corp-id}") + private String corpId; + + @Autowired + private MilvusServiceClient milvusClient; + + @Autowired + private MilvusProperties milvusProperties; + + @Autowired + private StaffMapper staffMapper; + + /** + * 查询客户指定日期的消息总结 + * + * @param customerId 客户企微外部联系人 userid + * @param date 日期 yyyy-MM-dd,为空则取当天 + * @return 总结列表,按 created_at 升序 + */ + public List queryDailySummary(String customerId, String date) { + if (customerId == null || customerId.trim().isEmpty()) { + throw new IllegalArgumentException("customerId 不能为空"); + } + + String queryDate = (date == null || date.trim().isEmpty()) + ? LocalDate.now().format(DATE_FORMATTER) + : date.trim(); + + String expr = String.format( + "(from_user == '%s' || to_user == '%s') && msg_time == '%s'", + escape(customerId), escape(customerId), escape(queryDate) + ); + + log.info("Query Milvus daily summary, customerId={}, date={}, expr={}", customerId, queryDate, expr); + + QueryParam queryParam = QueryParam.newBuilder() + .withCollectionName(milvusProperties.getCollection()) + .withExpr(expr) + .withOutFields(OUTPUT_FIELDS) + .withLimit(1000L) + .build(); + + R response = milvusClient.query(queryParam); + if (response.getStatus() != R.Status.Success.getCode()) { + String errorMsg = response.getException() != null ? response.getException().getMessage() : "未知错误"; + log.error("Milvus query failed: status={}, message={}", response.getStatus(), errorMsg); + throw new RuntimeException("查询 Milvus 失败: " + errorMsg); + } + + List result = convert(response.getData()); + fillStaffInfo(result, customerId); + + result.sort((a, b) -> { + long ca = a.getCreatedAt() != null ? a.getCreatedAt() : 0L; + long cb = b.getCreatedAt() != null ? b.getCreatedAt() : 0L; + return Long.compare(ca, cb); + }); + + log.info("Milvus daily summary found {} records", result.size()); + return result; + } + + private List convert(QueryResults queryResults) { + List list = new ArrayList<>(); + if (queryResults == null) { + return list; + } + + QueryResultsWrapper wrapper = new QueryResultsWrapper(queryResults); + List ids = getField(wrapper, "id"); + List msgTimes = getField(wrapper, "msg_time"); + List msgTypes = getField(wrapper, "msg_type"); + List fromUsers = getField(wrapper, "from_user"); + List toUsers = getField(wrapper, "to_user"); + List roomIds = getField(wrapper, "room_id"); + List messageContexts = getField(wrapper, "message_context"); + List summaries = getField(wrapper, "summary"); + List createdAts = getField(wrapper, "created_at"); + + int size = ids.size(); + for (int i = 0; i < size; i++) { + DailySummaryVO vo = new DailySummaryVO(); + vo.setId(getOrDefault(ids, i)); + vo.setMsgTime(getOrDefault(msgTimes, i)); + vo.setMsgType(getOrDefault(msgTypes, i)); + vo.setFromUser(getOrDefault(fromUsers, i)); + vo.setToUser(getOrDefault(toUsers, i)); + vo.setRoomId(getOrDefault(roomIds, i)); + vo.setMessageContext(getOrDefault(messageContexts, i)); + vo.setSummary(getOrDefault(summaries, i)); + vo.setCreatedAt(getOrDefault(createdAts, i)); + list.add(vo); + } + return list; + } + + /** + * 为每条总结填充销售老师信息 + */ + private void fillStaffInfo(List list, String customerId) { + if (list.isEmpty()) { + return; + } + + Set staffIds = new HashSet<>(); + for (DailySummaryVO vo : list) { + String staffId = resolveStaffId(vo, customerId); + if (staffId != null && !isExternalUserId(staffId)) { + staffIds.add(staffId); + } + } + + Map staffNameMap = new HashMap<>(); + if (!staffIds.isEmpty()) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Staff::getCorpId, corpId).in(Staff::getStaffId, staffIds); + List staffs = staffMapper.selectList(wrapper); + staffNameMap = staffs.stream() + .collect(Collectors.toMap( + Staff::getStaffId, + s -> s.getName() != null && !s.getName().isEmpty() ? s.getName() : s.getStaffId(), + (a, b) -> a + )); + } + + for (DailySummaryVO vo : list) { + String staffId = resolveStaffId(vo, customerId); + vo.setStaffId(staffId); + vo.setStaffName(staffNameMap.getOrDefault(staffId, staffId)); + } + } + + /** + * 从 from_user / to_user 中找出销售老师 ID + */ + private String resolveStaffId(DailySummaryVO vo, String customerId) { + if (customerId.equals(vo.getFromUser())) { + return vo.getToUser(); + } + if (customerId.equals(vo.getToUser())) { + return vo.getFromUser(); + } + // 兜底:返回非客户 ID + if (!isExternalUserId(vo.getFromUser())) { + return vo.getFromUser(); + } + return vo.getToUser(); + } + + private boolean isExternalUserId(String userId) { + return userId != null && (userId.startsWith("wm") || userId.startsWith("wo")); + } + + @SuppressWarnings("unchecked") + private List getField(QueryResultsWrapper wrapper, String fieldName) { + try { + FieldDataWrapper field = wrapper.getFieldWrapper(fieldName); + if (field == null) { + return new ArrayList<>(); + } + return (List) field.getFieldData(); + } catch (Exception e) { + log.warn("Parse Milvus field {} failed: {}", fieldName, e.getMessage()); + return new ArrayList<>(); + } + } + + private T getOrDefault(List list, int index) { + if (list == null || index < 0 || index >= list.size()) { + return null; + } + return list.get(index); + } + + private String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("'", "\\'"); + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/vo/DailySummaryVO.java b/backend/archive-service/src/main/java/com/artedu/archive/vo/DailySummaryVO.java new file mode 100644 index 0000000..6cd9c3a --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/vo/DailySummaryVO.java @@ -0,0 +1,34 @@ +package com.artedu.archive.vo; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 每日总结记录 VO + */ +@Data +public class DailySummaryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + private String msgTime; + private Integer msgType; + private String fromUser; + private String toUser; + private String roomId; + private String messageContext; + private String summary; + private Long createdAt; + + /** + * 销售老师企微 userId + */ + private String staffId; + + /** + * 销售老师名称 + */ + private String staffName; +} diff --git a/backend/archive-service/src/main/resources/application.yml b/backend/archive-service/src/main/resources/application.yml index aa58cab..975ed3b 100644 --- a/backend/archive-service/src/main/resources/application.yml +++ b/backend/archive-service/src/main/resources/application.yml @@ -46,6 +46,13 @@ wecom: sdk-path: ${WECOM_ARCHIVE_SDK_PATH:} mock-mode: ${WECOM_ARCHIVE_MOCK:false} +milvus: + host: ${MILVUS_HOST:8.159.132.53} + port: ${MILVUS_PORT:19530} + collection: ${MILVUS_COLLECTION:message_summary} + username: ${MILVUS_USERNAME:} + password: ${MILVUS_PASSWORD:} + mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl diff --git a/docker-compose.yml b/docker-compose.yml index 2a392df..0cc03b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,6 +111,11 @@ services: - WECOM_ARCHIVE_RSA_KEY=${WECOM_ARCHIVE_RSA_KEY:-} - WECOM_ARCHIVE_CALLBACK_TOKEN=${WECOM_ARCHIVE_CALLBACK_TOKEN:-} - WECOM_ARCHIVE_SDK_PATH=${WECOM_ARCHIVE_SDK_PATH:-/app/sdk} + - MILVUS_HOST=${MILVUS_HOST:-8.159.132.53} + - MILVUS_PORT=${MILVUS_PORT:-19530} + - MILVUS_USERNAME=${MILVUS_USERNAME:-root} + - MILVUS_PASSWORD=${MILVUS_PASSWORD:-Milvus} + - MILVUS_COLLECTION=${MILVUS_COLLECTION:-message_summary} # 如需真实企微SDK,取消注释下行并将Linux .so文件放入 ./sdk/linux/ # volumes: # - ./sdk/linux:/app/sdk diff --git a/frontend/admin/src/pages/CustomerSessionList.tsx b/frontend/admin/src/pages/CustomerSessionList.tsx index f7e62cd..8f44817 100644 --- a/frontend/admin/src/pages/CustomerSessionList.tsx +++ b/frontend/admin/src/pages/CustomerSessionList.tsx @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react' -import { Table, Input, Card, Avatar, Tag, Button, Modal, Row, Col, Statistic, Space, Spin } from 'antd' +import { Table, Input, Card, Avatar, Tag, Button, Modal, Row, Col, Statistic, Space, Spin, DatePicker, Empty, Typography } from 'antd' import { UserOutlined } from '@ant-design/icons' +import type { Dayjs } from 'dayjs' +import dayjs from 'dayjs' import request from '../utils/request' import ChatTimeline from '../components/ChatTimeline' @@ -31,6 +33,20 @@ interface SessionVO { sessionType: string } +interface DailySummaryVO { + id: number + msgTime: string + msgType: number + fromUser: string + toUser: string + roomId: string + messageContext: string + summary: string + createdAt: number + staffId: string + staffName: string +} + export default function CustomerSessionList() { const [data, setData] = useState([]) const [loading, setLoading] = useState(false) @@ -43,6 +59,11 @@ export default function CustomerSessionList() { const [sessionLoading, setSessionLoading] = useState(false) const [chatVisible, setChatVisible] = useState(false) const [selectedSession, setSelectedSession] = useState(null) + const [summaryVisible, setSummaryVisible] = useState(false) + const [summaryCustomer, setSummaryCustomer] = useState(null) + const [summaryDate, setSummaryDate] = useState(dayjs()) + const [summaryList, setSummaryList] = useState([]) + const [summaryLoading, setSummaryLoading] = useState(false) const loadData = async (page = 1, pageSize = 20) => { setLoading(true) @@ -101,6 +122,35 @@ export default function CustomerSessionList() { setChatVisible(true) } + const loadDailySummary = async (customerId: string, date: Dayjs) => { + setSummaryLoading(true) + try { + const res: any = await request.get('/v1/archive/daily-summary', { + params: { + customerId, + date: date.format('YYYY-MM-DD'), + } + }) + setSummaryList(res.data || []) + } finally { + setSummaryLoading(false) + } + } + + const openDailySummary = (record: CustomerVO) => { + setSummaryCustomer(record) + setSummaryDate(dayjs()) + setSummaryVisible(true) + loadDailySummary(record.customerId, dayjs()) + } + + const handleSummaryDateChange = (date: Dayjs | null) => { + if (date && summaryCustomer) { + setSummaryDate(date) + loadDailySummary(summaryCustomer.customerId, date) + } + } + const columns = [ { title: '客户', @@ -183,11 +233,16 @@ export default function CustomerSessionList() { }, { title: '操作', - width: 120, + width: 180, render: (_: any, record: CustomerVO) => ( - openDetail(record)}> - 查看会话 - + + openDetail(record)}> + 查看会话 + + openDailySummary(record)}> + 每日总结 + + ), }, ] @@ -330,6 +385,58 @@ export default function CustomerSessionList() { /> )} + + {/* 每日总结弹窗 */} + + + {summaryCustomer?.name?.charAt(0)} + + {summaryCustomer?.name || summaryCustomer?.customerId} 的每日总结 + + } + open={summaryVisible} + onCancel={() => setSummaryVisible(false)} + width={800} + footer={null} + > + + + 选择日期: + + + + + {summaryList.length === 0 ? ( + + ) : ( + + {summaryList.map((item) => ( + + + {item.summary || '-'} + + {item.messageContext && ( + + + 对话上下文: + + {item.messageContext} + + + )} + + ))} + + )} + + ) }