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,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);
}
}