From d55ae221c2f7b2782803d69c840e459ac44f9ea9 Mon Sep 17 00:00:00 2001 From: jqb Date: Wed, 17 Jun 2026 10:40:46 +0800 Subject: [PATCH] feat: add daily summary feature and customer stats calibration This commit adds multiple major features and improvements: 1. Adds Milvus vector database configuration and client integration 2. Implements daily summary query API for customer chat records 3. Adds customer statistics calibration functions, including single and full recalculation 4. Optimizes session list merging logic to handle bidirectional session IDs 5. Adds new mapper methods for customer message statistics query 6. Fixes message statistics update logic to avoid duplicate counting 7. Adds daily scheduled task to reset customer daily message count 8. Adds frontend page for viewing customer daily summaries --- backend/archive-service/pom.xml | 1 + .../artedu/archive/config/MilvusConfig.java | 49 ++++ .../archive/config/MilvusProperties.java | 18 ++ .../controller/ArchiveContactController.java | 15 ++ .../controller/DailySummaryController.java | 40 ++++ .../archive/mapper/ArchiveMessageMapper.java | 13 + .../service/ArchiveContactService.java | 110 +++++++++ .../archive/service/ArchivePullService.java | 7 +- .../service/ArchiveSessionService.java | 88 +++++-- .../archive/service/DailySummaryService.java | 224 ++++++++++++++++++ .../com/artedu/archive/vo/DailySummaryVO.java | 34 +++ .../src/main/resources/application.yml | 7 + docker-compose.yml | 5 + .../admin/src/pages/CustomerSessionList.tsx | 117 ++++++++- 14 files changed, 696 insertions(+), 32 deletions(-) create mode 100644 backend/archive-service/src/main/java/com/artedu/archive/config/MilvusConfig.java create mode 100644 backend/archive-service/src/main/java/com/artedu/archive/config/MilvusProperties.java create mode 100644 backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java create mode 100644 backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java create mode 100644 backend/archive-service/src/main/java/com/artedu/archive/vo/DailySummaryVO.java 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) => ( - + + + + ), }, ] @@ -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} +
+
+ )} +
+ ))} +
+ )} +
+
) }