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

View File

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

View File

@@ -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) {
}

View File

@@ -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<Object> 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<ChatDashboardTrend> queryDashboardTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId) {
assertAvailable();
List<Object> 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<ChatDashboardTrend> queryDashboardHourlyTrends(LocalDateTime startTime,
LocalDateTime endTime,
BigInteger tenantId) {
assertAvailable();
List<Object> 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<ChatAssistantUsageRank> queryAssistantUsageRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit) {
assertAvailable();
int safeLimit = Math.max(limit, 1);
List<Object> 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<LocalDate> 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<Object> 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<Object> args) {
if (query == null) {
return;

View File

@@ -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<ChatDashboardTrend> queryTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId);
/**
* 查询聊天小时趋势。
*
* @param startTime 开始时间,包含
* @param endTime 结束时间,不包含
* @param tenantId 租户 ID空表示全局
* @return 聊天小时趋势
*/
List<ChatDashboardTrend> queryHourlyTrends(LocalDateTime startTime, LocalDateTime endTime, BigInteger tenantId);
/**
* 查询智能体使用排行。
*
* @param startDate 开始日期,包含当天
* @param endDate 结束日期,不包含当天
* @param tenantId 租户 ID空表示全局
* @param limit 返回条数
* @return 智能体使用排行
*/
List<ChatAssistantUsageRank> queryAssistantUsageRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit);
/**
* 当前分析库是否可用。
*
* @return true 表示可用
*/
boolean available();
}

View File

@@ -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<ChatDashboardTrend> 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<ChatDashboardTrend> 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<ChatAssistantUsageRank> 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();
}
}