From 5827ecde42214e1c5099865a2bf799686b678fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Sun, 19 Apr 2026 17:40:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=B7=A5=E4=BD=9C=E5=8F=B0=E8=81=8A=E5=A4=A9=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作台接入聊天消息总数、会话总数、活跃智能体数、趋势与 Top5 排行 - dashboard 接口新增 chatStatus 与聊天统计字段,分析库不可用时明确降级 - today 维度按小时聚合聊天趋势,并补充后端查询与测试覆盖 --- .../dashboard/DashboardChatStatusVo.java | 27 ++ .../DashboardDistributionItemVo.java | 42 +++ .../model/dashboard/DashboardOverviewVo.java | 10 + .../model/dashboard/DashboardSummaryVo.java | 30 ++ .../model/dashboard/DashboardTrendItemVo.java | 20 ++ .../dashboard/impl/DashboardServiceImpl.java | 312 ++++++++++++++---- .../impl/DashboardServiceImplTest.java | 197 +++++++++++ .../domain/dto/ChatAssistantUsageRank.java | 17 + .../domain/dto/ChatDashboardSummary.java | 20 ++ .../domain/dto/ChatDashboardTrend.java | 11 + .../ChatAnalyticalDBRepository.java | 186 ++++++++++- .../service/ChatDashboardQueryService.java | 67 ++++ .../impl/ChatDashboardQueryServiceImpl.java | 105 ++++++ .../V3__analyticaldb_dashboard_tenant.sql | 18 + easyflow-ui-admin/app/src/api/dashboard.ts | 15 + .../src/views/dashboard/workspace/index.vue | 219 ++++++++++-- 16 files changed, 1206 insertions(+), 90 deletions(-) create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardChatStatusVo.java create mode 100644 easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantUsageRank.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardSummary.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardTrend.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatDashboardQueryService.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatDashboardQueryServiceImpl.java create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/analyticaldb/V3__analyticaldb_dashboard_tenant.sql diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardChatStatusVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardChatStatusVo.java new file mode 100644 index 0000000..7fb6a3c --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardChatStatusVo.java @@ -0,0 +1,27 @@ +package tech.easyflow.admin.model.dashboard; + +/** + * 聊天统计可用状态。 + */ +public class DashboardChatStatusVo { + + private Boolean available; + + private String message; + + public Boolean getAvailable() { + return available; + } + + public void setAvailable(Boolean available) { + this.available = available; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java index fd92bc3..305a5a3 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java @@ -1,5 +1,7 @@ package tech.easyflow.admin.model.dashboard; +import java.math.BigInteger; + /** * 工作台分布/排行项。 */ @@ -21,6 +23,14 @@ public class DashboardDistributionItemVo { private Long knowledgeBaseTotal; + private BigInteger assistantId; + + private Long messageTotal; + + private Long sessionTotal; + + private Double avgMessagePerSession; + public String getKey() { return key; } @@ -84,4 +94,36 @@ public class DashboardDistributionItemVo { public void setKnowledgeBaseTotal(Long knowledgeBaseTotal) { this.knowledgeBaseTotal = knowledgeBaseTotal; } + + public BigInteger getAssistantId() { + return assistantId; + } + + public void setAssistantId(BigInteger assistantId) { + this.assistantId = assistantId; + } + + public Long getMessageTotal() { + return messageTotal; + } + + public void setMessageTotal(Long messageTotal) { + this.messageTotal = messageTotal; + } + + public Long getSessionTotal() { + return sessionTotal; + } + + public void setSessionTotal(Long sessionTotal) { + this.sessionTotal = sessionTotal; + } + + public Double getAvgMessagePerSession() { + return avgMessagePerSession; + } + + public void setAvgMessagePerSession(Double avgMessagePerSession) { + this.avgMessagePerSession = avgMessagePerSession; + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewVo.java index c9cfa60..0b27a65 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewVo.java @@ -10,6 +10,8 @@ public class DashboardOverviewVo { private DashboardSummaryVo summary; + private DashboardChatStatusVo chatStatus; + private List trends; private List distribution; @@ -34,6 +36,14 @@ public class DashboardOverviewVo { this.trends = trends; } + public DashboardChatStatusVo getChatStatus() { + return chatStatus; + } + + public void setChatStatus(DashboardChatStatusVo chatStatus) { + this.chatStatus = chatStatus; + } + public List getDistribution() { return distribution; } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java index 52264b7..ef4b201 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java @@ -15,6 +15,12 @@ public class DashboardSummaryVo { private Long knowledgeBaseTotal; + private Long chatMessageTotal; + + private Long chatSessionTotal; + + private Long activeAssistantTotal; + public Long getUserTotal() { return userTotal; } @@ -54,4 +60,28 @@ public class DashboardSummaryVo { public void setKnowledgeBaseTotal(Long knowledgeBaseTotal) { this.knowledgeBaseTotal = knowledgeBaseTotal; } + + public Long getChatMessageTotal() { + return chatMessageTotal; + } + + public void setChatMessageTotal(Long chatMessageTotal) { + this.chatMessageTotal = chatMessageTotal; + } + + public Long getChatSessionTotal() { + return chatSessionTotal; + } + + public void setChatSessionTotal(Long chatSessionTotal) { + this.chatSessionTotal = chatSessionTotal; + } + + public Long getActiveAssistantTotal() { + return activeAssistantTotal; + } + + public void setActiveAssistantTotal(Long activeAssistantTotal) { + this.activeAssistantTotal = activeAssistantTotal; + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardTrendItemVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardTrendItemVo.java index 27fa840..cff6a4a 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardTrendItemVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardTrendItemVo.java @@ -11,6 +11,10 @@ public class DashboardTrendItemVo { private Long activeUserTotal; + private Long chatMessageTotal; + + private Long chatSessionTotal; + public String getKey() { return key; } @@ -34,4 +38,20 @@ public class DashboardTrendItemVo { public void setActiveUserTotal(Long activeUserTotal) { this.activeUserTotal = activeUserTotal; } + + public Long getChatMessageTotal() { + return chatMessageTotal; + } + + public void setChatMessageTotal(Long chatMessageTotal) { + this.chatMessageTotal = chatMessageTotal; + } + + public Long getChatSessionTotal() { + return chatSessionTotal; + } + + public void setChatSessionTotal(Long chatSessionTotal) { + this.chatSessionTotal = chatSessionTotal; + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java index 3331257..276029b 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java @@ -1,17 +1,22 @@ package tech.easyflow.admin.service.dashboard.impl; -import com.easyagents.flow.core.chain.ChainStatus; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.row.Db; -import com.mybatisflex.core.row.Row; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo; import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardOverviewVo; import tech.easyflow.admin.model.dashboard.DashboardSummaryVo; import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo; import tech.easyflow.admin.service.dashboard.DashboardService; +import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; +import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; +import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; +import tech.easyflow.chatlog.service.ChatDashboardQueryService; import tech.easyflow.common.constant.Constants; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.web.exceptions.BusinessException; @@ -40,24 +45,38 @@ import java.util.stream.Collectors; @Service public class DashboardServiceImpl implements DashboardService { + private static final Logger log = LoggerFactory.getLogger(DashboardServiceImpl.class); private static final ZoneId DEFAULT_ZONE_ID = ZoneId.systemDefault(); + private static final String CHAT_UNAVAILABLE_MESSAGE = "聊天数据不可用"; + private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5; + @Resource private SysAccountRoleService sysAccountRoleService; + @Resource private SysRoleService sysRoleService; + @Resource + private ChatDashboardQueryService chatDashboardQueryService; + + /** + * 获取工作台总览信息。 + * + * @param loginAccount 当前登录账号 + * @param query 查询条件 + * @return 工作台总览 + */ @Override public DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query) { DashboardQueryContext context = buildContext(loginAccount, query); - DashboardSummaryVo summary = buildSummary(context); - List trends = buildTrends(context); - List distribution = buildDistribution(context, summary); + ChatDashboardPayload chatPayload = buildChatPayload(context, summary); DashboardOverviewVo result = new DashboardOverviewVo(); result.setSummary(summary); - result.setTrends(trends); - result.setDistribution(distribution); + result.setChatStatus(chatPayload.chatStatus); + result.setTrends(chatPayload.trends); + result.setDistribution(chatPayload.distribution); DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery(); normalizedQuery.setRange(context.range); @@ -66,6 +85,12 @@ public class DashboardServiceImpl implements DashboardService { return result; } + /** + * 构建顶部汇总卡片。 + * + * @param context 查询上下文 + * @return 汇总结果 + */ private DashboardSummaryVo buildSummary(DashboardQueryContext context) { DashboardSummaryVo summary = new DashboardSummaryVo(); summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context)); @@ -73,72 +98,127 @@ public class DashboardServiceImpl implements DashboardService { summary.setBotTotal(countScopedTable("tb_bot", "b", false, context)); summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context)); summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context)); + summary.setChatMessageTotal(0L); + summary.setChatSessionTotal(0L); + summary.setActiveAssistantTotal(0L); return summary; } - private List buildTrends(DashboardQueryContext context) { - List buckets = buildBuckets(context.range); - String bucketFormat = "today".equals(context.range) ? "%Y-%m-%d %H:00:00" : "%Y-%m-%d"; + /** + * 构建聊天统计载荷。 + * + * @param context 查询上下文 + * @param summary 汇总结果 + * @return 聊天统计载荷 + */ + private ChatDashboardPayload buildChatPayload(DashboardQueryContext context, DashboardSummaryVo summary) { + DashboardChatStatusVo chatStatus = new DashboardChatStatusVo(); + chatStatus.setAvailable(Boolean.TRUE); + chatStatus.setMessage(""); - Map activeUserMap = queryActiveUserTrend(context, bucketFormat); + if (!chatDashboardQueryService.available()) { + chatStatus.setAvailable(Boolean.FALSE); + chatStatus.setMessage(CHAT_UNAVAILABLE_MESSAGE); + summary.setChatMessageTotal(0L); + summary.setChatSessionTotal(0L); + summary.setActiveAssistantTotal(0L); + return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>()); + } + + LocalDate startDate = context.startTime.toLocalDate(); + LocalDate endDate = context.endTime.toLocalDate(); + try { + ChatDashboardSummary chatSummary = chatDashboardQueryService.querySummary(startDate, endDate, context.tenantFilterId); + summary.setChatMessageTotal(chatSummary.messageTotal()); + summary.setChatSessionTotal(chatSummary.sessionTotal()); + summary.setActiveAssistantTotal(chatSummary.activeAssistantTotal()); + + List rawTrends = "today".equals(context.range) + ? chatDashboardQueryService.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId) + : chatDashboardQueryService.queryTrends(startDate, endDate, context.tenantFilterId); + List trends = buildTrendItems(context.range, rawTrends); + + List rawRanks = chatDashboardQueryService.queryAssistantUsageRanks( + startDate, + endDate, + context.tenantFilterId, + DEFAULT_ASSISTANT_RANK_LIMIT + ); + List distribution = buildAssistantDistribution(rawRanks); + return new ChatDashboardPayload(chatStatus, trends, distribution); + } catch (Exception ex) { + log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}", + context.range, + context.tenantFilterId, + ex); + chatStatus.setAvailable(Boolean.FALSE); + chatStatus.setMessage(CHAT_UNAVAILABLE_MESSAGE); + summary.setChatMessageTotal(0L); + summary.setChatSessionTotal(0L); + summary.setActiveAssistantTotal(0L); + return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>()); + } + } + + /** + * 构建聊天趋势项,缺失日期补 0。 + * + * @param range 时间范围 + * @param rawTrends 原始趋势 + * @return 趋势项 + */ + private List buildTrendItems(String range, List rawTrends) { + List buckets = buildBuckets(range); + Map trendMap = new HashMap<>(); + for (ChatDashboardTrend rawTrend : rawTrends) { + trendMap.put(rawTrend.bucketKey(), rawTrend); + } List items = new ArrayList<>(buckets.size()); for (TimeBucket bucket : buckets) { - long activeUserTotal = activeUserMap.getOrDefault(bucket.key, 0L); - + ChatDashboardTrend trend = trendMap.get(bucket.key); DashboardTrendItemVo item = new DashboardTrendItemVo(); item.setKey(bucket.key); item.setLabel(bucket.label); - item.setActiveUserTotal(activeUserTotal); + item.setActiveUserTotal(0L); + item.setChatMessageTotal(trend == null ? 0L : trend.messageTotal()); + item.setChatSessionTotal(trend == null ? 0L : trend.sessionTotal()); items.add(item); } return items; } - private List buildDistribution(DashboardQueryContext context, DashboardSummaryVo summary) { - return buildResourceDistribution(summary); - } - - private List buildResourceDistribution(DashboardSummaryVo summary) { - List items = new ArrayList<>(); - items.add(buildPlatformItem("userTotal", "用户总量", summary.getUserTotal())); - items.add(buildPlatformItem("activeUserTotal", "活跃用户", summary.getActiveUserTotal())); - items.add(buildPlatformItem("botTotal", "助手数量", summary.getBotTotal())); - items.add(buildPlatformItem("workflowTotal", "工作流数量", summary.getWorkflowTotal())); - items.add(buildPlatformItem("knowledgeBaseTotal", "知识库数量", summary.getKnowledgeBaseTotal())); + /** + * 构建智能体使用排行。 + * + * @param ranks 原始排行数据 + * @return 页面排行项 + */ + private List buildAssistantDistribution(List ranks) { + List items = new ArrayList<>(ranks.size()); + for (ChatAssistantUsageRank rank : ranks) { + DashboardDistributionItemVo item = new DashboardDistributionItemVo(); + item.setKey(rank.assistantId() == null ? "" : rank.assistantId().toString()); + item.setAssistantId(rank.assistantId()); + item.setLabel(resolveAssistantLabel(rank.assistantId(), rank.assistantName())); + item.setMessageTotal(rank.messageTotal()); + item.setSessionTotal(rank.sessionTotal()); + item.setAvgMessagePerSession(calculateAvg(rank.messageTotal(), rank.sessionTotal())); + item.setValue(rank.messageTotal()); + items.add(item); + } return items; } - private DashboardDistributionItemVo buildPlatformItem(String key, String label, Long value) { - DashboardDistributionItemVo item = new DashboardDistributionItemVo(); - item.setKey(key); - item.setLabel(label); - item.setValue(defaultLong(value)); - return item; - } - - private Map queryActiveUserTrend(DashboardQueryContext context, String bucketFormat) { - StringBuilder sql = new StringBuilder(); - List params = new ArrayList<>(); - - sql.append("SELECT DATE_FORMAT(l.created, '").append(bucketFormat).append("') AS bucket_key, ") - .append("COUNT(DISTINCT l.account_id) AS total ") - .append("FROM tb_sys_log l ") - .append("INNER JOIN tb_sys_account a ON a.id = l.account_id AND a.is_deleted IS NULL ") - .append("WHERE l.created >= ? AND l.created < ? "); - params.add(toDate(context.startTime)); - params.add(toDate(context.endTime)); - appendOptionalTenantFilter(sql, params, context.tenantFilterId, "a.tenant_id"); - appendOptionalDeptFilter(sql, params, context.deptFilterId, "a.dept_id"); - sql.append("GROUP BY bucket_key ORDER BY bucket_key ASC"); - - Map data = new HashMap<>(); - for (Row row : Db.selectListBySql(sql.toString(), params.toArray())) { - data.put(asString(row.get("bucket_key")), asLong(row.get("total"))); - } - return data; - } - + /** + * 按租户统计平台资源数量。 + * + * @param tableName 表名 + * @param alias 别名 + * @param containsLogicDelete 是否包含逻辑删除条件 + * @param context 查询上下文 + * @return 统计值 + */ private long countScopedTable(String tableName, String alias, boolean containsLogicDelete, DashboardQueryContext context) { StringBuilder sql = new StringBuilder(); List params = new ArrayList<>(); @@ -152,6 +232,12 @@ public class DashboardServiceImpl implements DashboardService { return queryForLong(sql.toString(), params); } + /** + * 统计当前时间范围内活跃用户数。 + * + * @param context 查询上下文 + * @return 活跃用户数 + */ private long countActiveUsers(DashboardQueryContext context) { StringBuilder sql = new StringBuilder(); List params = new ArrayList<>(); @@ -167,11 +253,26 @@ public class DashboardServiceImpl implements DashboardService { return queryForLong(sql.toString(), params); } + /** + * 执行 count SQL 并返回 long 值。 + * + * @param sql SQL + * @param params 参数 + * @return long 值 + */ private long queryForLong(String sql, List params) { Object result = Db.selectObject(sql, params.toArray()); return asLong(result); } + /** + * 追加租户过滤。 + * + * @param sql SQL 构造器 + * @param params 参数列表 + * @param tenantId 租户 ID + * @param columnName 列名 + */ private void appendOptionalTenantFilter(StringBuilder sql, List params, BigInteger tenantId, String columnName) { if (tenantId != null) { sql.append(" AND ").append(columnName).append(" = ? "); @@ -179,6 +280,14 @@ public class DashboardServiceImpl implements DashboardService { } } + /** + * 追加部门过滤。 + * + * @param sql SQL 构造器 + * @param params 参数列表 + * @param deptId 部门 ID + * @param columnName 列名 + */ private void appendOptionalDeptFilter(StringBuilder sql, List params, BigInteger deptId, String columnName) { if (deptId != null) { sql.append(" AND ").append(columnName).append(" = ? "); @@ -186,6 +295,13 @@ public class DashboardServiceImpl implements DashboardService { } } + /** + * 构建查询上下文。 + * + * @param loginAccount 当前登录账号 + * @param query 查询条件 + * @return 查询上下文 + */ private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardOverviewQuery query) { DashboardQueryContext context = new DashboardQueryContext(); context.range = normalizeRange(query == null ? null : query.getRange()); @@ -203,11 +319,16 @@ public class DashboardServiceImpl implements DashboardService { context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN); } - context.tenantFilterId = context.superAdmin ? null : loginAccount.getTenantId(); - + context.tenantFilterId = context.superAdmin || loginAccount == null ? null : loginAccount.getTenantId(); return context; } + /** + * 判断当前登录账号是否为超管。 + * + * @param loginAccount 当前登录账号 + * @return true 表示超管 + */ private boolean isSuperAdmin(LoginAccount loginAccount) { if (loginAccount == null || loginAccount.getId() == null) { return false; @@ -228,6 +349,12 @@ public class DashboardServiceImpl implements DashboardService { return sysRoleService.count(roleWrapper) > 0; } + /** + * 归一化时间范围参数。 + * + * @param range 原始时间范围 + * @return 规范化后的时间范围 + */ private String normalizeRange(String range) { if (!StringUtils.hasText(range)) { return "7d"; @@ -238,10 +365,15 @@ public class DashboardServiceImpl implements DashboardService { throw new BusinessException("不支持的时间范围: " + range); } + /** + * 构建时间桶。 + * + * @param range 时间范围 + * @return 时间桶列表 + */ private List buildBuckets(String range) { List buckets = new ArrayList<>(); LocalDate today = LocalDate.now(DEFAULT_ZONE_ID); - if ("today".equals(range)) { DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"); DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("HH:00"); @@ -264,18 +396,22 @@ public class DashboardServiceImpl implements DashboardService { return buckets; } + /** + * 把 LocalDateTime 转换为 Date。 + * + * @param dateTime 时间 + * @return Date + */ private Date toDate(LocalDateTime dateTime) { return Date.from(dateTime.atZone(DEFAULT_ZONE_ID).toInstant()); } - private long defaultLong(Long value) { - return value == null ? 0L : value; - } - - private String asString(Object value) { - return value == null ? "" : String.valueOf(value); - } - + /** + * 解析对象为 long 值。 + * + * @param value 原始对象 + * @return long 值 + */ private long asLong(Object value) { if (value == null) { return 0L; @@ -286,6 +422,37 @@ public class DashboardServiceImpl implements DashboardService { return Long.parseLong(String.valueOf(value)); } + /** + * 计算平均每会话消息数。 + * + * @param messageTotal 消息总数 + * @param sessionTotal 会话总数 + * @return 平均值 + */ + private double calculateAvg(long messageTotal, long sessionTotal) { + if (sessionTotal <= 0) { + return 0D; + } + return (double) messageTotal / (double) sessionTotal; + } + + /** + * 解析智能体展示名称。 + * + * @param assistantId 智能体 ID + * @param assistantName 智能体名称 + * @return 展示名称 + */ + private String resolveAssistantLabel(BigInteger assistantId, String assistantName) { + if (StringUtils.hasText(assistantName)) { + return assistantName.trim(); + } + return assistantId == null ? "智能体-未知" : "智能体-" + assistantId; + } + + /** + * 工作台查询上下文。 + */ private static class DashboardQueryContext { private String range; private BigInteger tenantFilterId; @@ -295,6 +462,9 @@ public class DashboardServiceImpl implements DashboardService { private LocalDateTime endTime; } + /** + * 时间桶。 + */ private static class TimeBucket { private final String key; private final String label; @@ -304,4 +474,14 @@ public class DashboardServiceImpl implements DashboardService { this.label = label; } } + + /** + * 聊天统计页面载荷。 + */ + private record ChatDashboardPayload( + DashboardChatStatusVo chatStatus, + List trends, + List distribution + ) { + } } diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java new file mode 100644 index 0000000..620c9b8 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java @@ -0,0 +1,197 @@ +package tech.easyflow.admin.service.dashboard.impl; + +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo; +import tech.easyflow.admin.model.dashboard.DashboardSummaryVo; +import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo; +import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; +import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; +import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; +import tech.easyflow.chatlog.service.ChatDashboardQueryService; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link DashboardServiceImpl} 测试。 + */ +public class DashboardServiceImplTest { + + /** + * 验证分析库不可用时返回明确不可用状态,且趋势与排行为空。 + * + * @throws Exception 反射调用失败 + */ + @Test + public void shouldReturnUnavailableChatPayloadWhenAnalyticalDbIsDisabled() throws Exception { + DashboardServiceImpl service = new DashboardServiceImpl(); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + when(chatDashboardQueryService.available()).thenReturn(false); + setField(service, "chatDashboardQueryService", chatDashboardQueryService); + + Object context = newContext("7d", null); + DashboardSummaryVo summary = new DashboardSummaryVo(); + Object payload = invokeBuildChatPayload(service, context, summary); + + Object chatStatus = readField(payload, "chatStatus"); + List trends = (List) readField(payload, "trends"); + List distribution = (List) readField(payload, "distribution"); + + Assert.assertFalse(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable"))); + Assert.assertEquals(invokeGetter(chatStatus, "getMessage"), "聊天数据不可用"); + Assert.assertTrue(trends.isEmpty()); + Assert.assertTrue(distribution.isEmpty()); + Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(0L)); + Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(0L)); + Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(0L)); + } + + /** + * 验证 today 返回 24 个小时点位,且排行名称与均值回退正确。 + * + * @throws Exception 反射调用失败 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldBuildHourlyTrendForToday() throws Exception { + DashboardServiceImpl service = new DashboardServiceImpl(); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + String currentHourKey = LocalDateTime.of(LocalDate.now(), LocalTime.of(10, 0)) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00")); + when(chatDashboardQueryService.available()).thenReturn(true); + when(chatDashboardQueryService.querySummary(any(), any(), any())) + .thenReturn(new ChatDashboardSummary(3L, 9L, 1L)); + when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any())) + .thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 3L, 9L))); + when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class))) + .thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 3L, 9L))); + setField(service, "chatDashboardQueryService", chatDashboardQueryService); + + Object context = newContext("today", BigInteger.valueOf(9)); + DashboardSummaryVo summary = new DashboardSummaryVo(); + Object payload = invokeBuildChatPayload(service, context, summary); + + Object chatStatus = readField(payload, "chatStatus"); + List trends = (List) readField(payload, "trends"); + List distribution = (List) readField(payload, "distribution"); + + Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable"))); + Assert.assertEquals(trends.size(), 24); + Assert.assertEquals(trends.get(0).getLabel(), "00:00"); + Assert.assertEquals(trends.get(10).getKey(), currentHourKey); + Assert.assertEquals(trends.get(10).getLabel(), "10:00"); + Assert.assertEquals(trends.get(10).getChatMessageTotal(), Long.valueOf(9L)); + Assert.assertEquals(trends.get(10).getChatSessionTotal(), Long.valueOf(3L)); + Assert.assertEquals(trends.get(11).getChatMessageTotal(), Long.valueOf(0L)); + Assert.assertEquals(trends.get(11).getChatSessionTotal(), Long.valueOf(0L)); + Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(9L)); + Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(3L)); + Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(1L)); + Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1"); + Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D)); + } + + /** + * 构造查询上下文。 + * + * @param range 时间范围 + * @param tenantId 租户 ID + * @return 查询上下文实例 + * @throws Exception 反射失败 + */ + private Object newContext(String range, BigInteger tenantId) throws Exception { + Class contextClass = Class.forName( + "tech.easyflow.admin.service.dashboard.impl.DashboardServiceImpl$DashboardQueryContext" + ); + Constructor constructor = contextClass.getDeclaredConstructor(); + constructor.setAccessible(true); + Object context = constructor.newInstance(); + setField(context, "range", range); + setField(context, "tenantFilterId", tenantId); + setField(context, "startTime", LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN)); + setField(context, "endTime", LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN)); + return context; + } + + /** + * 调用私有聊天载荷组装方法。 + * + * @param service service + * @param context 上下文 + * @param summary 汇总对象 + * @return 载荷 + * @throws Exception 反射失败 + */ + private Object invokeBuildChatPayload(DashboardServiceImpl service, Object context, DashboardSummaryVo summary) + throws Exception { + Method method = DashboardServiceImpl.class.getDeclaredMethod( + "buildChatPayload", + context.getClass(), + DashboardSummaryVo.class + ); + method.setAccessible(true); + return method.invoke(service, context, summary); + } + + /** + * 读取对象字段。 + * + * @param target 目标对象 + * @param fieldName 字段名 + * @return 字段值 + * @throws Exception 反射失败 + */ + private Object readField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(target); + } + + /** + * 调用 getter。 + * + * @param target 目标对象 + * @param methodName 方法名 + * @return 返回值 + * @throws Exception 反射失败 + */ + private Object invokeGetter(Object target, String methodName) throws Exception { + Method method = target.getClass().getMethod(methodName); + return method.invoke(target); + } + + /** + * 通过反射设置字段值。 + * + * @param target 目标对象 + * @param fieldName 字段名 + * @param value 字段值 + * @throws Exception 反射失败 + */ + private void setField(Object target, String fieldName, Object value) throws Exception { + Class current = target.getClass(); + while (current != null) { + try { + Field field = current.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + return; + } catch (NoSuchFieldException ignored) { + current = current.getSuperclass(); + } + } + throw new IllegalArgumentException("未找到字段: " + fieldName); + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantUsageRank.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantUsageRank.java new file mode 100644 index 0000000..b28e3a6 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantUsageRank.java @@ -0,0 +1,17 @@ +package tech.easyflow.chatlog.domain.dto; + +import java.math.BigInteger; + +/** + * 聊天助手使用排行项。 + * + * @param assistantId 智能体 ID + * @param assistantName 智能体名称 + * @param sessionTotal 会话总数 + * @param messageTotal 消息总数 + */ +public record ChatAssistantUsageRank(BigInteger assistantId, + String assistantName, + long sessionTotal, + long messageTotal) { +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardSummary.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardSummary.java new file mode 100644 index 0000000..99e71a3 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardSummary.java @@ -0,0 +1,20 @@ +package tech.easyflow.chatlog.domain.dto; + +/** + * 聊天工作台汇总统计。 + * + * @param sessionTotal 会话总数 + * @param messageTotal 消息总数 + * @param activeAssistantTotal 活跃智能体数 + */ +public record ChatDashboardSummary(long sessionTotal, long messageTotal, long activeAssistantTotal) { + + /** + * 创建空汇总结果。 + * + * @return 空汇总结果 + */ + public static ChatDashboardSummary empty() { + return new ChatDashboardSummary(0L, 0L, 0L); + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardTrend.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardTrend.java new file mode 100644 index 0000000..476418f --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardTrend.java @@ -0,0 +1,11 @@ +package tech.easyflow.chatlog.domain.dto; + +/** + * 聊天工作台趋势项。 + * + * @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00 + * @param sessionTotal 会话总数 + * @param messageTotal 消息总数 + */ +public record ChatDashboardTrend(String bucketKey, long sessionTotal, long messageTotal) { +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepository.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepository.java index ae750e3..a8be436 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepository.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepository.java @@ -3,6 +3,10 @@ package tech.easyflow.chatlog.repository.analyticaldb; import org.springframework.beans.factory.ObjectProvider; import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; +import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; +import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; import tech.easyflow.chatlog.domain.dto.ChatSessionPage; @@ -19,6 +23,7 @@ import java.math.BigInteger; import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Collections; @@ -204,6 +209,160 @@ public class ChatAnalyticalDBRepository { ); } + /** + * 查询聊天工作台汇总统计。 + * + * @param startDate 开始日期,包含 + * @param endDate 结束日期,不包含 + * @param tenantId 租户 ID,空表示全局 + * @return 聊天汇总统计 + */ + public ChatDashboardSummary queryDashboardSummary(LocalDate startDate, LocalDate endDate, BigInteger tenantId) { + assertAvailable(); + List args = new java.util.ArrayList<>(); + StringBuilder sql = new StringBuilder(); + sql.append("SELECT ") + .append("ifNull(sum(message_count), 0) AS message_total, ") + .append("ifNull(sum(session_count), 0) AS session_total, ") + .append("uniqExact(dimension_id) AS active_assistant_total ") + .append("FROM dws_chat_assistant_day ") + .append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)"); + args.add(startDate.toString()); + args.add(endDate.toString()); + appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); + + ChatDashboardSummary summary = analyticalDBOperations.queryOne( + sql.toString(), + (rs, rowNum) -> new ChatDashboardSummary( + rs.getLong("session_total"), + rs.getLong("message_total"), + rs.getLong("active_assistant_total") + ), + args.toArray() + ); + return summary == null ? ChatDashboardSummary.empty() : summary; + } + + /** + * 查询聊天工作台日趋势。 + * + * @param startDate 开始日期,包含 + * @param endDate 结束日期,不包含 + * @param tenantId 租户 ID,空表示全局 + * @return 日趋势列表 + */ + public List queryDashboardTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId) { + assertAvailable(); + List args = new java.util.ArrayList<>(); + StringBuilder sql = new StringBuilder(); + sql.append("SELECT toString(stat_date) AS bucket_key, ") + .append("ifNull(sum(message_count), 0) AS message_total, ") + .append("ifNull(sum(session_count), 0) AS session_total ") + .append("FROM dws_chat_assistant_day ") + .append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)"); + args.add(startDate.toString()); + args.add(endDate.toString()); + appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); + sql.append(" GROUP BY stat_date ORDER BY stat_date ASC"); + + return analyticalDBOperations.query( + sql.toString(), + (rs, rowNum) -> new ChatDashboardTrend( + rs.getString("bucket_key"), + rs.getLong("session_total"), + rs.getLong("message_total") + ), + args.toArray() + ); + } + + /** + * 查询聊天工作台小时趋势。 + * + * @param startTime 开始时间,包含 + * @param endTime 结束时间,不包含 + * @param tenantId 租户 ID,空表示全局 + * @return 小时趋势列表 + */ + public List queryDashboardHourlyTrends(LocalDateTime startTime, + LocalDateTime endTime, + BigInteger tenantId) { + assertAvailable(); + List args = new java.util.ArrayList<>(); + StringBuilder sql = new StringBuilder(); + sql.append("SELECT formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ") + .append("count() AS message_total, ") + .append("uniqExact(l.session_id) AS session_total ") + .append("FROM ods_chat_log l ") + .append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ") + .append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)"); + args.add(CH_DATE_TIME_FORMATTER.format(startTime)); + args.add(CH_DATE_TIME_FORMATTER.format(endTime)); + appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id"); + sql.append(" GROUP BY bucket_key ORDER BY bucket_key ASC"); + + return analyticalDBOperations.query( + sql.toString(), + (rs, rowNum) -> new ChatDashboardTrend( + rs.getString("bucket_key"), + rs.getLong("session_total"), + rs.getLong("message_total") + ), + args.toArray() + ); + } + + /** + * 查询聊天助手使用排行。 + * + * @param startDate 开始日期,包含 + * @param endDate 结束日期,不包含 + * @param tenantId 租户 ID,空表示全局 + * @param limit 返回条数 + * @return 排行列表 + */ + public List queryAssistantUsageRanks(LocalDate startDate, + LocalDate endDate, + BigInteger tenantId, + int limit) { + assertAvailable(); + int safeLimit = Math.max(limit, 1); + List args = new java.util.ArrayList<>(); + StringBuilder sql = new StringBuilder(); + sql.append("SELECT agg.assistant_id, snapshot.assistant_name, agg.session_total, agg.message_total ") + .append("FROM (") + .append("SELECT dimension_id AS assistant_id, ") + .append("ifNull(sum(session_count), 0) AS session_total, ") + .append("ifNull(sum(message_count), 0) AS message_total ") + .append("FROM dws_chat_assistant_day ") + .append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)"); + args.add(startDate.toString()); + args.add(endDate.toString()); + appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); + sql.append(" GROUP BY dimension_id") + .append(") agg ") + .append("LEFT JOIN (") + .append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ") + .append("FROM ods_chat_session FINAL WHERE is_deleted = 0 "); + appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); + sql.append(" GROUP BY assistant_id") + .append(") snapshot ON snapshot.assistant_id = agg.assistant_id ") + .append("ORDER BY agg.message_total DESC, agg.session_total DESC, agg.assistant_id ASC ") + .append("LIMIT ?"); + args.add(safeLimit); + + return analyticalDBOperations.query( + sql.toString(), + (rs, rowNum) -> new ChatAssistantUsageRank( + bigInteger(rs.getObject("assistant_id")), + rs.getString("assistant_name"), + rs.getLong("session_total"), + rs.getLong("message_total") + ), + args.toArray() + ); + } + public void refreshDws(Set dates) { if (!enabled() || dates.isEmpty()) { return; @@ -212,8 +371,15 @@ public class ChatAnalyticalDBRepository { String dateLiteral = date.toString(); analyticalDBOperations.update("ALTER TABLE dws_chat_assistant_day DELETE WHERE stat_date = toDate(?)", dateLiteral); analyticalDBOperations.update("INSERT INTO dws_chat_assistant_day " + - "SELECT toDate(created) AS stat_date, assistant_id AS dimension_id, uniqExact(session_id) AS session_count, count() AS message_count " + - "FROM ods_chat_log WHERE toDate(created) = toDate(?) GROUP BY stat_date, dimension_id", dateLiteral); + "SELECT toDate(l.created) AS stat_date, " + + "l.assistant_id AS dimension_id, " + + "ifNull(s.tenant_id, toUInt64(0)) AS tenant_id, " + + "uniqExact(l.session_id) AS session_count, " + + "count() AS message_count " + + "FROM ods_chat_log l " + + "LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id " + + "WHERE toDate(l.created) = toDate(?) " + + "GROUP BY stat_date, dimension_id, tenant_id", dateLiteral); analyticalDBOperations.update("ALTER TABLE dws_chat_user_day DELETE WHERE stat_date = toDate(?)", dateLiteral); analyticalDBOperations.update("INSERT INTO dws_chat_user_day " + @@ -313,6 +479,22 @@ public class ChatAnalyticalDBRepository { return new BigInteger(String.valueOf(value)); } + /** + * 追加可选租户过滤,超管场景可不传租户。 + * + * @param sql SQL 构造器 + * @param args 参数列表 + * @param tenantId 租户 ID + * @param columnName 列名 + */ + private void appendOptionalTenantFilter(StringBuilder sql, List args, BigInteger tenantId, String columnName) { + if (tenantId == null || !StringUtils.hasText(columnName)) { + return; + } + sql.append(" AND ").append(columnName).append(" = ?"); + args.add(tenantId); + } + private void appendSessionFilters(ChatSessionFilterQuery query, StringBuilder sql, List args) { if (query == null) { return; diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatDashboardQueryService.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatDashboardQueryService.java new file mode 100644 index 0000000..c2065c6 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatDashboardQueryService.java @@ -0,0 +1,67 @@ +package tech.easyflow.chatlog.service; + +import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; +import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; +import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; + +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 聊天工作台统计查询服务。 + */ +public interface ChatDashboardQueryService { + + /** + * 查询聊天汇总指标。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @return 聊天汇总指标 + */ + ChatDashboardSummary querySummary(LocalDate startDate, LocalDate endDate, BigInteger tenantId); + + /** + * 查询聊天日趋势。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @return 聊天日趋势 + */ + List queryTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId); + + /** + * 查询聊天小时趋势。 + * + * @param startTime 开始时间,包含 + * @param endTime 结束时间,不包含 + * @param tenantId 租户 ID,空表示全局 + * @return 聊天小时趋势 + */ + List queryHourlyTrends(LocalDateTime startTime, LocalDateTime endTime, BigInteger tenantId); + + /** + * 查询智能体使用排行。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @param limit 返回条数 + * @return 智能体使用排行 + */ + List queryAssistantUsageRanks(LocalDate startDate, + LocalDate endDate, + BigInteger tenantId, + int limit); + + /** + * 当前分析库是否可用。 + * + * @return true 表示可用 + */ + boolean available(); +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatDashboardQueryServiceImpl.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatDashboardQueryServiceImpl.java new file mode 100644 index 0000000..7f14704 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatDashboardQueryServiceImpl.java @@ -0,0 +1,105 @@ +package tech.easyflow.chatlog.service.impl; + +import org.springframework.stereotype.Service; +import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; +import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; +import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; +import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository; +import tech.easyflow.chatlog.service.ChatDashboardQueryService; + +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +/** + * 基于分析库的聊天工作台统计查询实现。 + */ +@Service +public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService { + + private final ChatAnalyticalDBRepository analyticalDBRepository; + + public ChatDashboardQueryServiceImpl(ChatAnalyticalDBRepository analyticalDBRepository) { + this.analyticalDBRepository = analyticalDBRepository; + } + + /** + * 查询聊天汇总指标。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @return 聊天汇总指标 + */ + @Override + public ChatDashboardSummary querySummary(LocalDate startDate, LocalDate endDate, BigInteger tenantId) { + if (!available()) { + return ChatDashboardSummary.empty(); + } + return analyticalDBRepository.queryDashboardSummary(startDate, endDate, tenantId); + } + + /** + * 查询聊天日趋势。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @return 聊天日趋势 + */ + @Override + public List queryTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId) { + if (!available()) { + return Collections.emptyList(); + } + return analyticalDBRepository.queryDashboardTrends(startDate, endDate, tenantId); + } + + /** + * 查询聊天小时趋势。 + * + * @param startTime 开始时间,包含 + * @param endTime 结束时间,不包含 + * @param tenantId 租户 ID,空表示全局 + * @return 聊天小时趋势 + */ + @Override + public List queryHourlyTrends(LocalDateTime startTime, LocalDateTime endTime, BigInteger tenantId) { + if (!available()) { + return Collections.emptyList(); + } + return analyticalDBRepository.queryDashboardHourlyTrends(startTime, endTime, tenantId); + } + + /** + * 查询智能体使用排行。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @param limit 返回条数 + * @return 智能体使用排行 + */ + @Override + public List queryAssistantUsageRanks(LocalDate startDate, + LocalDate endDate, + BigInteger tenantId, + int limit) { + if (!available()) { + return Collections.emptyList(); + } + return analyticalDBRepository.queryAssistantUsageRanks(startDate, endDate, tenantId, limit); + } + + /** + * 当前分析库是否可用。 + * + * @return true 表示可用 + */ + @Override + public boolean available() { + return analyticalDBRepository.enabled(); + } +} diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/analyticaldb/V3__analyticaldb_dashboard_tenant.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/analyticaldb/V3__analyticaldb_dashboard_tenant.sql new file mode 100644 index 0000000..54d4570 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/analyticaldb/V3__analyticaldb_dashboard_tenant.sql @@ -0,0 +1,18 @@ +ALTER TABLE dws_chat_assistant_day + ADD COLUMN IF NOT EXISTS `tenant_id` UInt64 DEFAULT 0 AFTER `dimension_id`; + +ALTER TABLE dws_chat_assistant_day DELETE WHERE `tenant_id` = 0; + +INSERT INTO dws_chat_assistant_day +SELECT + toDate(l.created) AS stat_date, + l.assistant_id AS dimension_id, + ifNull(s.tenant_id, toUInt64(0)) AS tenant_id, + uniqExact(l.session_id) AS session_count, + count() AS message_count +FROM ods_chat_log l +LEFT JOIN ( + SELECT id, tenant_id + FROM ods_chat_session FINAL +) s ON s.id = l.session_id +GROUP BY stat_date, dimension_id, tenant_id; diff --git a/easyflow-ui-admin/app/src/api/dashboard.ts b/easyflow-ui-admin/app/src/api/dashboard.ts index 9b9adff..378c53f 100644 --- a/easyflow-ui-admin/app/src/api/dashboard.ts +++ b/easyflow-ui-admin/app/src/api/dashboard.ts @@ -8,30 +8,45 @@ export interface DashboardOverviewQuery { export interface DashboardSummary { activeUserTotal: number; + activeAssistantTotal: number; botTotal: number; + chatMessageTotal: number; + chatSessionTotal: number; knowledgeBaseTotal: number; userTotal: number; workflowTotal: number; } +export interface DashboardChatStatus { + available: boolean; + message: string; +} + export interface DashboardTrendItem { activeUserTotal: number; + chatMessageTotal: number; + chatSessionTotal: number; key: string; label: string; } export interface DashboardDistributionItem { activeUserTotal: number; + assistantId?: number | string; + avgMessagePerSession?: number; botTotal: number; key: string; knowledgeBaseTotal: number; label: string; + messageTotal?: number; + sessionTotal?: number; userTotal: number; value: number; workflowTotal: number; } export interface DashboardOverviewResponse { + chatStatus: DashboardChatStatus; distribution: DashboardDistributionItem[]; query: DashboardOverviewQuery; summary: DashboardSummary; diff --git a/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue b/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue index 7f5dc82..4104fdf 100644 --- a/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue +++ b/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue @@ -2,6 +2,7 @@ import type { EchartsUIType } from '@easyflow/plugins/echarts'; import type { + DashboardDistributionItem, DashboardOverviewQuery, DashboardOverviewResponse, DashboardRange, @@ -21,6 +22,7 @@ import { import { AnalysisChartCard } from '@easyflow/common-ui'; import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts'; import { useUserStore } from '@easyflow/stores'; +import { convertToRgb } from '@easyflow/utils'; import { RefreshRight } from '@element-plus/icons-vue'; import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus'; @@ -28,6 +30,7 @@ import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus'; import { getDashboardOverview } from '#/api/dashboard'; interface SummaryCardItem { + available?: boolean; label: string; value: string; } @@ -54,27 +57,48 @@ const rangeOptions: Array<{ label: string; value: DashboardRange }> = [ ]; const emptySummary: DashboardSummary = { + activeAssistantTotal: 0, activeUserTotal: 0, botTotal: 0, + chatMessageTotal: 0, + chatSessionTotal: 0, knowledgeBaseTotal: 0, userTotal: 0, workflowTotal: 0, }; const summary = computed(() => overview.value?.summary ?? emptySummary); -const trends = computed( - () => overview.value?.trends ?? [], +const trends = computed(() => overview.value?.trends ?? []); +const distribution = computed( + () => overview.value?.distribution ?? [], +); +const chatAvailable = computed(() => overview.value?.chatStatus?.available !== false); +const chatStatusMessage = computed( + () => overview.value?.chatStatus?.message || '聊天数据不可用', ); const summaryCards = computed(() => [ - { label: '用户总量', value: formatCount(summary.value.userTotal) }, - { label: '活跃用户', value: formatCount(summary.value.activeUserTotal) }, { label: '助手数量', value: formatCount(summary.value.botTotal) }, { label: '工作流数量', value: formatCount(summary.value.workflowTotal) }, { label: '知识库数量', value: formatCount(summary.value.knowledgeBaseTotal), }, + { + available: chatAvailable.value, + label: '聊天消息总数', + value: formatOptionalCount(summary.value.chatMessageTotal, chatAvailable.value), + }, + { + available: chatAvailable.value, + label: '聊天会话总数', + value: formatOptionalCount(summary.value.chatSessionTotal, chatAvailable.value), + }, + { + available: chatAvailable.value, + label: '活跃智能体数', + value: formatOptionalCount(summary.value.activeAssistantTotal, chatAvailable.value), + }, ]); const updatedAtText = computed(() => { @@ -131,15 +155,24 @@ async function loadOverview() { async function renderCharts() { await nextTick(); + if (!chatAvailable.value) { + return; + } renderTrendChart(); } function renderTrendChart() { const xAxisData = trends.value.map((item) => item.label); - const activeUserData = trends.value.map((item) => item.activeUserTotal); + const messageData = trends.value.map((item) => item.chatMessageTotal); + const sessionData = trends.value.map((item) => item.chatSessionTotal); + const primaryColor = getChartTokenColor('--primary'); + const successColor = getChartTokenColor('--success'); + const axisColor = getChartTokenColor('--border'); + const tooltipLineColor = getChartTokenColor('--accent'); + const textColor = getChartTokenColor('--foreground'); renderTrendEcharts({ - color: ['hsl(var(--primary))'], + color: [primaryColor, successColor], grid: { bottom: 18, containLabel: true, @@ -148,21 +181,38 @@ function renderTrendChart() { top: 24, }, legend: { - itemGap: 18, - top: 0, + icon: 'circle', + itemGap: 24, + itemHeight: 10, + itemWidth: 10, + padding: [4, 12, 8, 12], + textStyle: { + color: textColor, + fontSize: 14, + fontWeight: 500, + }, + top: 4, }, tooltip: { + axisPointer: { + lineStyle: { + color: tooltipLineColor, + width: 1, + }, + type: 'line', + }, trigger: 'axis', }, xAxis: { axisLine: { lineStyle: { - color: 'hsl(var(--border))', + color: axisColor, }, }, axisTick: { show: false, }, + boundaryGap: false, data: xAxisData, type: 'category', }, @@ -175,7 +225,7 @@ function renderTrendChart() { }, splitLine: { lineStyle: { - color: 'hsl(var(--border))', + color: axisColor, type: 'dashed', }, }, @@ -183,10 +233,57 @@ function renderTrendChart() { }, series: [ { - data: activeUserData, - name: '活跃用户', + data: messageData, + emphasis: { + focus: 'none', + itemStyle: { + borderColor: '#ffffff', + borderWidth: 3, + color: primaryColor, + }, + scale: true, + }, + itemStyle: { + borderColor: primaryColor, + borderWidth: 2, + color: '#ffffff', + }, + lineStyle: { + color: primaryColor, + width: 3, + }, + name: '消息数', smooth: true, - symbolSize: 8, + showSymbol: false, + symbol: 'circle', + symbolSize: 9, + type: 'line', + }, + { + data: sessionData, + emphasis: { + focus: 'none', + itemStyle: { + borderColor: '#ffffff', + borderWidth: 3, + color: successColor, + }, + scale: true, + }, + itemStyle: { + borderColor: successColor, + borderWidth: 2, + color: '#ffffff', + }, + lineStyle: { + color: successColor, + width: 3, + }, + name: '会话数', + smooth: true, + showSymbol: false, + symbol: 'circle', + symbolSize: 9, type: 'line', }, ], @@ -205,6 +302,28 @@ function formatCount(value?: number) { return Number(value || 0).toLocaleString('zh-CN'); } +function formatOptionalCount(value: number | undefined, available: boolean) { + return available ? formatCount(value) : '--'; +} + +function getChartTokenColor(variableName: string) { + if (typeof window === 'undefined') { + return '#3b82f6'; + } + const value = getComputedStyle(document.documentElement) + .getPropertyValue(variableName) + .trim(); + return value ? convertToRgb(`hsl(${value})`) : '#3b82f6'; +} + +function formatAvg(value?: number) { + const safeValue = Number(value || 0); + return safeValue.toLocaleString('zh-CN', { + maximumFractionDigits: 1, + minimumFractionDigits: safeValue > 0 && safeValue < 10 ? 1 : 0, + }); +} + function formatDateTime(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { @@ -270,7 +389,7 @@ onBeforeUnmount(() => {
{