feat: 增强管理员工作台聊天统计看板

- 工作台接入聊天消息总数、会话总数、活跃智能体数、趋势与 Top5 排行

- dashboard 接口新增 chatStatus 与聊天统计字段,分析库不可用时明确降级

- today 维度按小时聚合聊天趋势,并补充后端查询与测试覆盖
This commit is contained in:
2026-04-19 17:40:01 +08:00
parent 1d8b9d9662
commit 5827ecde42
16 changed files with 1206 additions and 90 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -10,6 +10,8 @@ public class DashboardOverviewVo {
private DashboardSummaryVo summary;
private DashboardChatStatusVo chatStatus;
private List<DashboardTrendItemVo> trends;
private List<DashboardDistributionItemVo> 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<DashboardDistributionItemVo> getDistribution() {
return distribution;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<DashboardTrendItemVo> trends = buildTrends(context);
List<DashboardDistributionItemVo> 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<DashboardTrendItemVo> buildTrends(DashboardQueryContext context) {
List<TimeBucket> 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<String, Long> 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<ChatDashboardTrend> rawTrends = "today".equals(context.range)
? chatDashboardQueryService.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId)
: chatDashboardQueryService.queryTrends(startDate, endDate, context.tenantFilterId);
List<DashboardTrendItemVo> trends = buildTrendItems(context.range, rawTrends);
List<ChatAssistantUsageRank> rawRanks = chatDashboardQueryService.queryAssistantUsageRanks(
startDate,
endDate,
context.tenantFilterId,
DEFAULT_ASSISTANT_RANK_LIMIT
);
List<DashboardDistributionItemVo> 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<DashboardTrendItemVo> buildTrendItems(String range, List<ChatDashboardTrend> rawTrends) {
List<TimeBucket> buckets = buildBuckets(range);
Map<String, ChatDashboardTrend> trendMap = new HashMap<>();
for (ChatDashboardTrend rawTrend : rawTrends) {
trendMap.put(rawTrend.bucketKey(), rawTrend);
}
List<DashboardTrendItemVo> 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<DashboardDistributionItemVo> buildDistribution(DashboardQueryContext context, DashboardSummaryVo summary) {
return buildResourceDistribution(summary);
}
private List<DashboardDistributionItemVo> buildResourceDistribution(DashboardSummaryVo summary) {
List<DashboardDistributionItemVo> 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<DashboardDistributionItemVo> buildAssistantDistribution(List<ChatAssistantUsageRank> ranks) {
List<DashboardDistributionItemVo> 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<String, Long> queryActiveUserTrend(DashboardQueryContext context, String bucketFormat) {
StringBuilder sql = new StringBuilder();
List<Object> 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<String, Long> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<TimeBucket> buildBuckets(String range) {
List<TimeBucket> 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<DashboardTrendItemVo> trends,
List<DashboardDistributionItemVo> distribution
) {
}
}

View File

@@ -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<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) 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);
}
}