diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dashboard/DashboardController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dashboard/DashboardController.java index d44df4e..52d45ef 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dashboard/DashboardController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dashboard/DashboardController.java @@ -1,15 +1,25 @@ package tech.easyflow.admin.controller.dashboard; import cn.dev33.satoken.annotation.SaCheckPermission; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardOverviewVo; +import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo; +import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery; import tech.easyflow.admin.service.dashboard.DashboardService; import tech.easyflow.common.domain.Result; import tech.easyflow.common.satoken.util.SaTokenUtil; +import jakarta.servlet.http.HttpServletResponse; + +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + /** * 管理端工作台统计接口。 */ @@ -28,4 +38,26 @@ public class DashboardController { public Result overview(DashboardOverviewQuery query) { return Result.ok(dashboardService.getOverview(SaTokenUtil.getLoginAccount(), query)); } + + @GetMapping("/user-ranks") + @SaCheckPermission("/api/v1/dashboard/query") + public Result> userRanks(DashboardUserRankQuery query) { + return Result.ok(dashboardService.getUserRanks(SaTokenUtil.getLoginAccount(), query)); + } + + @GetMapping(value = "/user-ranks/export", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @SaCheckPermission("/api/v1/dashboard/query") + public void exportUserRanks(DashboardUserRankQuery query, HttpServletResponse response) throws Exception { + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + String fileName = URLEncoder.encode("dashboard_user_ranks_" + timestamp, "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); + dashboardService.exportUserRanks( + SaTokenUtil.getLoginAccount(), + query, + response.getOutputStream() + ); + } } 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 918411d..fff122b 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 @@ -18,8 +18,6 @@ public class DashboardOverviewVo { private List distribution; - private List userRanks; - private DashboardOverviewQuery query; private Date updatedAt; @@ -64,14 +62,6 @@ public class DashboardOverviewVo { this.assistantTrends = assistantTrends; } - public List getUserRanks() { - return userRanks; - } - - public void setUserRanks(List userRanks) { - this.userRanks = userRanks; - } - public DashboardOverviewQuery getQuery() { return query; } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankItemVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankItemVo.java index 601b743..e051c51 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankItemVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankItemVo.java @@ -9,14 +9,25 @@ public class DashboardUserRankItemVo { private BigInteger userId; + /** + * 最终展示名称。 + */ private String label; + /** + * 登录账号。 + */ + private String loginName; + + /** + * 昵称。 + */ + private String nickname; + private Long sessionTotal; private Long messageTotal; - private Long assistantTotal; - public BigInteger getUserId() { return userId; } @@ -33,6 +44,22 @@ public class DashboardUserRankItemVo { this.label = label; } + public String getLoginName() { + return loginName; + } + + public void setLoginName(String loginName) { + this.loginName = loginName; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + public Long getSessionTotal() { return sessionTotal; } @@ -48,12 +75,4 @@ public class DashboardUserRankItemVo { public void setMessageTotal(Long messageTotal) { this.messageTotal = messageTotal; } - - public Long getAssistantTotal() { - return assistantTotal; - } - - public void setAssistantTotal(Long assistantTotal) { - this.assistantTotal = assistantTotal; - } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankQuery.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankQuery.java new file mode 100644 index 0000000..6f0320b --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankQuery.java @@ -0,0 +1,46 @@ +package tech.easyflow.admin.model.dashboard; + +import java.math.BigInteger; + +/** + * 用户活跃榜查询参数。 + */ +public class DashboardUserRankQuery { + + private String range; + private String startDate; + private String endDate; + private BigInteger assistantId; + + public String getRange() { + return range; + } + + public void setRange(String range) { + this.range = range; + } + + public String getStartDate() { + return startDate; + } + + public void setStartDate(String startDate) { + this.startDate = startDate; + } + + public String getEndDate() { + return endDate; + } + + public void setEndDate(String endDate) { + this.endDate = endDate; + } + + public BigInteger getAssistantId() { + return assistantId; + } + + public void setAssistantId(BigInteger assistantId) { + this.assistantId = assistantId; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/DashboardService.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/DashboardService.java index 3f5683f..2c1fa52 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/DashboardService.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/DashboardService.java @@ -2,12 +2,42 @@ package tech.easyflow.admin.service.dashboard; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardOverviewVo; +import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo; +import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery; import tech.easyflow.common.entity.LoginAccount; +import java.io.OutputStream; +import java.util.List; + /** * 工作台统计服务。 */ public interface DashboardService { + /** + * 获取工作台总览。 + * + * @param loginAccount 当前登录账号 + * @param query 查询条件 + * @return 工作台总览 + */ DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query); + + /** + * 获取用户活跃榜。 + * + * @param loginAccount 当前登录账号 + * @param query 查询条件 + * @return 用户活跃榜 + */ + List getUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query); + + /** + * 导出用户活跃榜。 + * + * @param loginAccount 当前登录账号 + * @param query 查询条件 + * @param outputStream 输出流 + */ + void exportUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query, OutputStream outputStream); } 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 cefba09..94e1147 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,11 +1,15 @@ package tech.easyflow.admin.service.dashboard.impl; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.write.style.column.SimpleColumnWidthStyleStrategy; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.row.Db; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import tech.easyflow.ai.entity.Bot; +import tech.easyflow.ai.service.BotService; import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; @@ -15,6 +19,7 @@ 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.model.dashboard.DashboardUserRankItemVo; +import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery; import tech.easyflow.admin.service.dashboard.DashboardService; import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank; import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend; @@ -25,13 +30,16 @@ 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; +import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccountRole; import tech.easyflow.system.entity.SysRole; +import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountRoleService; import tech.easyflow.system.service.SysRoleService; import javax.annotation.Resource; +import java.io.OutputStream; import java.math.BigInteger; import java.time.LocalDate; import java.time.LocalDateTime; @@ -58,6 +66,7 @@ public class DashboardServiceImpl implements DashboardService { private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5; private static final int DEFAULT_ASSISTANT_TREND_LIMIT = 8; private static final int DEFAULT_USER_RANK_LIMIT = 5; + private static final int USER_RANK_EXPORT_COLUMN_WIDTH = 20; @Resource private SysAccountRoleService sysAccountRoleService; @@ -71,6 +80,12 @@ public class DashboardServiceImpl implements DashboardService { @Resource private ChatDashboardQueryService chatDashboardQueryService; + @Resource + private BotService botService; + + @Resource + private CategoryPermissionService categoryPermissionService; + /** * 获取工作台总览信息。 * @@ -90,7 +105,6 @@ public class DashboardServiceImpl implements DashboardService { result.setTrends(chatPayload.trends); result.setAssistantTrends(chatPayload.assistantTrends); result.setDistribution(chatPayload.distribution); - result.setUserRanks(chatPayload.userRanks); DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery(); normalizedQuery.setRange(context.range); @@ -101,6 +115,37 @@ public class DashboardServiceImpl implements DashboardService { return result; } + /** + * 获取用户活跃榜。 + * + * @param loginAccount 当前登录账号 + * @param query 查询条件 + * @return 用户活跃榜 + */ + @Override + public List getUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query) { + DashboardQueryContext context = buildContext(loginAccount, query); + return queryUserRanks(context, DEFAULT_USER_RANK_LIMIT); + } + + /** + * 导出用户活跃榜。 + * + * @param loginAccount 当前登录账号 + * @param query 查询条件 + * @param outputStream 输出流 + */ + @Override + public void exportUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query, OutputStream outputStream) { + DashboardQueryContext context = buildContext(loginAccount, query); + List userRanks = queryUserRanks(context, null); + EasyExcel.write(outputStream) + .head(buildUserRankExportHead()) + .registerWriteHandler(new SimpleColumnWidthStyleStrategy(USER_RANK_EXPORT_COLUMN_WIDTH)) + .sheet("用户活跃榜") + .doWrite(buildUserRankExportRows(userRanks)); + } + /** * 构建顶部汇总卡片。 * @@ -144,7 +189,6 @@ public class DashboardServiceImpl implements DashboardService { chatStatus, new ArrayList<>(), new ArrayList<>(), - new ArrayList<>(), new ArrayList<>() ); } @@ -176,14 +220,7 @@ public class DashboardServiceImpl implements DashboardService { List distribution = buildAssistantDistribution( rawRanks.subList(0, Math.min(DEFAULT_ASSISTANT_RANK_LIMIT, rawRanks.size())) ); - List rawUserRanks = chatDashboardQueryService.queryActiveUserRanks( - startDate, - endDate, - context.tenantFilterId, - DEFAULT_USER_RANK_LIMIT - ); - List userRanks = buildUserRanks(rawUserRanks); - return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution, userRanks); + return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution); } catch (Exception ex) { log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}", context.range, @@ -199,7 +236,6 @@ public class DashboardServiceImpl implements DashboardService { chatStatus, new ArrayList<>(), new ArrayList<>(), - new ArrayList<>(), new ArrayList<>() ); } @@ -328,6 +364,27 @@ public class DashboardServiceImpl implements DashboardService { return seriesList; } + /** + * 查询用户活跃排行。 + * + * @param context 查询上下文 + * @param limit 返回条数,空表示全部 + * @return 用户活跃排行 + */ + private List queryUserRanks(DashboardQueryContext context, Integer limit) { + if (!chatDashboardQueryService.available()) { + return new ArrayList<>(); + } + List rawUserRanks = chatDashboardQueryService.queryActiveUserRanks( + context.startTime.toLocalDate(), + context.endTime.toLocalDate(), + context.tenantFilterId, + context.assistantId, + limit + ); + return buildUserRanks(rawUserRanks); + } + /** * 构建用户活跃排行。 * @@ -335,20 +392,62 @@ public class DashboardServiceImpl implements DashboardService { * @return 页面排行项 */ private List buildUserRanks(List ranks) { + if (ranks == null || ranks.isEmpty()) { + return new ArrayList<>(); + } List items = new ArrayList<>(ranks.size()); - Map displayNameMap = resolveUserDisplayNameMap(ranks); + Map identityMap = resolveAccountIdentityMap(ranks); for (ChatActiveUserRank rank : ranks) { + ResolvedUserIdentity identity = resolveUserIdentity( + rank.userId(), + rank.userAccount(), + identityMap.get(rank.userId()) + ); DashboardUserRankItemVo item = new DashboardUserRankItemVo(); item.setUserId(rank.userId()); - item.setLabel(resolveUserLabel(rank.userId(), rank.userAccount(), displayNameMap)); + item.setLabel(identity.label); + item.setLoginName(identity.loginName); + item.setNickname(identity.nickname); item.setSessionTotal(rank.sessionTotal()); item.setMessageTotal(rank.messageTotal()); - item.setAssistantTotal(rank.assistantTotal()); items.add(item); } return items; } + /** + * 构建导出表头。 + * + * @return 表头 + */ + private List> buildUserRankExportHead() { + List> head = new ArrayList<>(4); + head.add(List.of("登录账号")); + head.add(List.of("昵称")); + head.add(List.of("会话数")); + head.add(List.of("消息数")); + return head; + } + + /** + * 构建导出数据行。 + * + * @param userRanks 用户排行 + * @return 数据行 + */ + private List> buildUserRankExportRows(List userRanks) { + List> rows = new ArrayList<>(userRanks.size()); + for (DashboardUserRankItemVo item : userRanks) { + List row = new ArrayList<>(4); + row.add(defaultIfBlank(item.getLoginName())); + row.add(defaultIfBlank(item.getNickname())); + row.add(String.valueOf(item.getSessionTotal() == null ? 0L : item.getSessionTotal())); + row.add(String.valueOf(item.getMessageTotal() == null ? 0L : item.getMessageTotal())); + rows.add(row); + } + return rows; + } + /** * 按租户统计平台资源数量。 * @@ -442,8 +541,47 @@ public class DashboardServiceImpl implements DashboardService { * @return 查询上下文 */ private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardOverviewQuery query) { + return buildContext( + loginAccount, + query == null ? null : query.getRange(), + query == null ? null : query.getStartDate(), + query == null ? null : query.getEndDate() + ); + } + + /** + * 构建用户榜查询上下文。 + * + * @param loginAccount 当前登录账号 + * @param query 用户榜查询 + * @return 查询上下文 + */ + private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardUserRankQuery query) { + DashboardQueryContext context = buildContext( + loginAccount, + query == null ? null : query.getRange(), + query == null ? null : query.getStartDate(), + query == null ? null : query.getEndDate() + ); + context.assistantId = validateAssistantId(loginAccount, query == null ? null : query.getAssistantId()); + return context; + } + + /** + * 构建基础查询上下文。 + * + * @param loginAccount 当前登录账号 + * @param range 时间范围 + * @param startDate 开始日期 + * @param endDate 结束日期 + * @return 查询上下文 + */ + private DashboardQueryContext buildContext(LoginAccount loginAccount, + String range, + String startDate, + String endDate) { DashboardQueryContext context = new DashboardQueryContext(); - context.range = normalizeRange(query == null ? null : query.getRange()); + context.range = normalizeRange(range); context.superAdmin = isSuperAdmin(loginAccount); LocalDate today = LocalDate.now(DEFAULT_ZONE_ID); @@ -463,8 +601,8 @@ public class DashboardServiceImpl implements DashboardService { context.queryStartDate = today.minusDays(29).toString(); context.queryEndDate = today.toString(); } else { - LocalDate customStartDate = parseRequiredDate(query == null ? null : query.getStartDate(), "开始日期不能为空"); - LocalDate customEndDate = parseRequiredDate(query == null ? null : query.getEndDate(), "结束日期不能为空"); + LocalDate customStartDate = parseRequiredDate(startDate, "开始日期不能为空"); + LocalDate customEndDate = parseRequiredDate(endDate, "结束日期不能为空"); if (customStartDate.isAfter(customEndDate)) { throw new BusinessException("开始日期不能晚于结束日期"); } @@ -645,7 +783,7 @@ public class DashboardServiceImpl implements DashboardService { * @param ranks 活跃排行 * @return 名称映射 */ - private Map resolveUserDisplayNameMap(List ranks) { + private Map resolveAccountIdentityMap(List ranks) { List userIds = ranks.stream() .map(ChatActiveUserRank::userId) .filter(java.util.Objects::nonNull) @@ -654,7 +792,24 @@ public class DashboardServiceImpl implements DashboardService { if (userIds.isEmpty()) { return new HashMap<>(); } - return sysAccountService.resolveDisplayNameMap(userIds); + QueryWrapper queryWrapper = QueryWrapper.create() + .select(SysAccount::getId, SysAccount::getLoginName, SysAccount::getNickname) + .in(SysAccount::getId, userIds); + List accounts = sysAccountService.list(queryWrapper); + Map identityMap = new HashMap<>(userIds.size()); + if (accounts == null) { + return identityMap; + } + for (SysAccount account : accounts) { + if (account == null || account.getId() == null) { + continue; + } + identityMap.put( + account.getId(), + new AccountIdentitySnapshot(trimToNull(account.getLoginName()), trimToNull(account.getNickname())) + ); + } + return identityMap; } /** @@ -676,20 +831,75 @@ public class DashboardServiceImpl implements DashboardService { * * @param userId 用户 ID * @param userAccount 聊天侧账号快照 - * @param displayNameMap 系统账号名称映射 - * @return 展示名称 + * @param snapshot 系统账号快照 + * @return 用户身份 */ - private String resolveUserLabel(BigInteger userId, String userAccount, Map displayNameMap) { - if (userId != null) { - String displayName = displayNameMap.get(userId); - if (StringUtils.hasText(displayName) && !displayName.equals(userId.toString())) { - return displayName; + private ResolvedUserIdentity resolveUserIdentity(BigInteger userId, + String userAccount, + AccountIdentitySnapshot snapshot) { + String loginName = snapshot == null ? null : snapshot.loginName; + String nickname = snapshot == null ? null : snapshot.nickname; + String trimmedUserAccount = trimToNull(userAccount); + if (StringUtils.hasText(loginName)) { + if (StringUtils.hasText(nickname)) { + return new ResolvedUserIdentity(loginName, nickname, loginName + "(" + nickname + ")"); } + return new ResolvedUserIdentity(loginName, nickname, loginName); } - if (StringUtils.hasText(userAccount)) { - return userAccount.trim(); + if (StringUtils.hasText(trimmedUserAccount)) { + return new ResolvedUserIdentity(trimmedUserAccount, nickname, trimmedUserAccount); } - return userId == null ? "用户-未知" : "用户-" + userId; + return new ResolvedUserIdentity(null, nickname, userId == null ? "用户-未知" : "用户-" + userId); + } + + /** + * 校验智能体筛选条件。 + * + * @param loginAccount 当前登录账号 + * @param assistantId 智能体 ID + * @return 规范化后的智能体 ID + */ + private BigInteger validateAssistantId(LoginAccount loginAccount, BigInteger assistantId) { + if (assistantId == null) { + return null; + } + Bot bot = botService.getById(assistantId); + if (bot == null || !Integer.valueOf(1).equals(bot.getStatus())) { + throw new BusinessException("聊天助手不存在或未启用"); + } + boolean visible = categoryPermissionService.canAccessCategory( + loginAccount, + "BOT", + bot.getCreatedBy(), + bot.getCategoryId() + ); + if (!visible) { + throw new BusinessException("聊天助手不存在或未启用"); + } + return assistantId; + } + + /** + * 归一化文本。 + * + * @param value 原始文本 + * @return 去空白后的文本 + */ + private String trimToNull(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + /** + * 把空文本转为空串。 + * + * @param value 原始文本 + * @return 输出文本 + */ + private String defaultIfBlank(String value) { + return StringUtils.hasText(value) ? value.trim() : ""; } /** @@ -699,6 +909,7 @@ public class DashboardServiceImpl implements DashboardService { private String range; private BigInteger tenantFilterId; private BigInteger deptFilterId; + private BigInteger assistantId; private boolean superAdmin; private LocalDateTime startTime; private LocalDateTime endTime; @@ -719,6 +930,34 @@ public class DashboardServiceImpl implements DashboardService { } } + /** + * 系统账号身份快照。 + */ + private static class AccountIdentitySnapshot { + private final String loginName; + private final String nickname; + + private AccountIdentitySnapshot(String loginName, String nickname) { + this.loginName = loginName; + this.nickname = nickname; + } + } + + /** + * 用户展示身份。 + */ + private static class ResolvedUserIdentity { + private final String loginName; + private final String nickname; + private final String label; + + private ResolvedUserIdentity(String loginName, String nickname, String label) { + this.loginName = loginName; + this.nickname = nickname; + this.label = label; + } + } + /** * 聊天统计页面载荷。 */ @@ -726,8 +965,7 @@ public class DashboardServiceImpl implements DashboardService { DashboardChatStatusVo chatStatus, List trends, List assistantTrends, - List distribution, - List userRanks + 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 index c5a59f7..f2c4e68 100644 --- 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 @@ -1,13 +1,18 @@ package tech.easyflow.admin.service.dashboard.impl; +import com.mybatisflex.core.query.QueryWrapper; +import org.apache.poi.ss.usermodel.WorkbookFactory; import org.testng.Assert; import org.testng.annotations.Test; +import tech.easyflow.ai.entity.Bot; +import tech.easyflow.ai.service.BotService; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardSummaryVo; import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo; import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo; +import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery; import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank; import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend; import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; @@ -15,8 +20,15 @@ import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; import tech.easyflow.chatlog.service.ChatDashboardQueryService; import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.SysAccountService; +import tech.easyflow.system.service.SysAccountRoleService; +import tech.easyflow.system.service.SysRoleService; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -25,13 +37,15 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.stream.IntStream; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -71,7 +85,7 @@ public class DashboardServiceImplTest { } /** - * 验证 today 返回 24 个小时点位,且排行名称与均值回退正确。 + * 验证 today 返回 24 个小时点位,且 overview 不再触发用户榜查询。 * * @throws Exception 反射调用失败 */ @@ -91,12 +105,7 @@ public class DashboardServiceImplTest { .thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 2L, 3L, 9L))); when(chatDashboardQueryService.queryAssistantHourlyTrends(any(), any(), any(), any())) .thenReturn(List.of(new ChatAssistantSessionTrend(BigInteger.ONE, "", currentHourKey, 3L))); - when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) - .thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 3L, 9L, 1L))); - SysAccountService sysAccountService = mock(SysAccountService.class); - when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(2), "演示用户")); setField(service, "chatDashboardQueryService", chatDashboardQueryService); - setField(service, "sysAccountService", sysAccountService); Object context = newContext("today", BigInteger.valueOf(9)); DashboardSummaryVo summary = new DashboardSummaryVo(); @@ -107,7 +116,6 @@ public class DashboardServiceImplTest { List assistantTrends = (List) readField(payload, "assistantTrends"); List distribution = (List) readField(payload, "distribution"); - List userRanks = (List) readField(payload, "userRanks"); Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable"))); Assert.assertEquals(trends.size(), 24); @@ -132,40 +140,31 @@ public class DashboardServiceImplTest { Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1"); Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D)); Assert.assertEquals(distribution.get(0).getAvgSessionPerUser(), Double.valueOf(1.5D)); - Assert.assertEquals(userRanks.get(0).getLabel(), "演示用户"); - Assert.assertEquals(userRanks.get(0).getAssistantTotal(), Long.valueOf(1L)); + verify(chatDashboardQueryService, never()).queryActiveUserRanks(any(), any(), any(), any(), any()); } /** * 验证当系统账号名称仅回退为纯 ID 时,仍优先继续回退到聊天侧账号。 - * - * @throws Exception 反射调用失败 */ @Test - @SuppressWarnings("unchecked") public void shouldFallbackToUserAccountWhenSystemDisplayNameIsOnlyId() throws Exception { DashboardServiceImpl service = new DashboardServiceImpl(); ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); when(chatDashboardQueryService.available()).thenReturn(true); - when(chatDashboardQueryService.querySummary(any(), any(), any())) - .thenReturn(new ChatDashboardSummary(1L, 1L, 1L, 1L)); - when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any())) - .thenReturn(List.of()); - when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class))) - .thenReturn(List.of()); - when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) + when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(), any())) .thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(9), "chat-user", 1L, 1L, 1L))); SysAccountService sysAccountService = mock(SysAccountService.class); - when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(9), "9")); + when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(9L, "", "仅昵称"))); setField(service, "chatDashboardQueryService", chatDashboardQueryService); setField(service, "sysAccountService", sysAccountService); - Object context = newContext("today", BigInteger.ONE); - DashboardSummaryVo summary = new DashboardSummaryVo(); - Object payload = invokeBuildChatPayload(service, context, summary); + DashboardUserRankQuery query = new DashboardUserRankQuery(); + query.setRange("7d"); - List userRanks = (List) readField(payload, "userRanks"); + List userRanks = service.getUserRanks(new LoginAccount(), query); Assert.assertEquals(userRanks.get(0).getLabel(), "chat-user"); + Assert.assertEquals(userRanks.get(0).getLoginName(), "chat-user"); + Assert.assertEquals(userRanks.get(0).getNickname(), "仅昵称"); } /** @@ -194,12 +193,7 @@ public class DashboardServiceImplTest { new ChatAssistantSessionTrend(BigInteger.ONE, "助手-A", LocalDate.now().toString(), 4L), new ChatAssistantSessionTrend(null, "未知助手", LocalDate.now().minusDays(3).toString(), 2L) )); - when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) - .thenReturn(List.of()); - SysAccountService sysAccountService = mock(SysAccountService.class); - when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of()); setField(service, "chatDashboardQueryService", chatDashboardQueryService); - setField(service, "sysAccountService", sysAccountService); Object context = newContext("7d", BigInteger.ONE); DashboardSummaryVo summary = new DashboardSummaryVo(); @@ -238,10 +232,7 @@ public class DashboardServiceImplTest { .thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 2L, 6L, 1L))); when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class))) .thenReturn(List.of()); - when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) - .thenReturn(List.of()); setField(service, "chatDashboardQueryService", chatDashboardQueryService); - setField(service, "sysAccountService", mock(SysAccountService.class)); Object context = newContext( "custom", @@ -321,10 +312,7 @@ public class DashboardServiceImplTest { new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", startDate.toString(), 2L), new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", endDate.toString(), 4L) )); - when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) - .thenReturn(List.of()); setField(service, "chatDashboardQueryService", chatDashboardQueryService); - setField(service, "sysAccountService", mock(SysAccountService.class)); Object context = newContext("30d", BigInteger.ONE); DashboardSummaryVo summary = new DashboardSummaryVo(); @@ -344,6 +332,150 @@ public class DashboardServiceImplTest { verify(chatDashboardQueryService).queryAssistantUsageRanks(any(), any(), any(), eq(8)); } + /** + * 验证用户榜筛选会透传 assistantId。 + */ + @Test + public void shouldQueryUserRanksWithAssistantFilter() throws Exception { + DashboardServiceImpl service = new DashboardServiceImpl(); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + BotService botService = mock(BotService.class); + CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class); + SysAccountService sysAccountService = mock(SysAccountService.class); + SysAccountRoleService sysAccountRoleService = mock(SysAccountRoleService.class); + SysRoleService sysRoleService = mock(SysRoleService.class); + + Bot bot = new Bot(); + bot.setId(BigInteger.TEN); + bot.setStatus(1); + bot.setCreatedBy(BigInteger.ONE); + bot.setCategoryId(BigInteger.valueOf(8)); + + when(chatDashboardQueryService.available()).thenReturn(true); + when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5))) + .thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 2L, 4L, 1L))); + when(botService.getById(BigInteger.TEN)).thenReturn(bot); + when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8)))) + .thenReturn(true); + when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(2L, "demo-user", "演示用户"))); + when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList()); + + setField(service, "chatDashboardQueryService", chatDashboardQueryService); + setField(service, "botService", botService); + setField(service, "categoryPermissionService", categoryPermissionService); + setField(service, "sysAccountService", sysAccountService); + setField(service, "sysAccountRoleService", sysAccountRoleService); + setField(service, "sysRoleService", sysRoleService); + + DashboardUserRankQuery query = new DashboardUserRankQuery(); + query.setRange("7d"); + query.setAssistantId(BigInteger.TEN); + + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setId(BigInteger.valueOf(12)); + loginAccount.setTenantId(BigInteger.valueOf(33)); + + List userRanks = service.getUserRanks(loginAccount, query); + Assert.assertEquals(userRanks.size(), 1); + Assert.assertEquals(userRanks.get(0).getLabel(), "demo-user(演示用户)"); + verify(chatDashboardQueryService).queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5)); + } + + /** + * 验证未启用智能体会被拒绝。 + */ + @Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用") + public void shouldRejectDisabledAssistantFilter() { + DashboardServiceImpl service = new DashboardServiceImpl(); + BotService botService = mock(BotService.class); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + + Bot bot = new Bot(); + bot.setId(BigInteger.TEN); + bot.setStatus(0); + + when(botService.getById(BigInteger.TEN)).thenReturn(bot); + when(chatDashboardQueryService.available()).thenReturn(true); + + setFieldSilently(service, "botService", botService); + setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService); + setFieldSilently(service, "categoryPermissionService", mock(CategoryPermissionService.class)); + setFieldSilently(service, "sysAccountService", mock(SysAccountService.class)); + + DashboardUserRankQuery query = new DashboardUserRankQuery(); + query.setRange("7d"); + query.setAssistantId(BigInteger.TEN); + service.getUserRanks(new LoginAccount(), query); + } + + /** + * 验证当前作用域不可见的智能体会被拒绝。 + */ + @Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用") + public void shouldRejectInvisibleAssistantFilter() { + DashboardServiceImpl service = new DashboardServiceImpl(); + BotService botService = mock(BotService.class); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class); + + Bot bot = new Bot(); + bot.setId(BigInteger.TEN); + bot.setStatus(1); + bot.setCreatedBy(BigInteger.ONE); + bot.setCategoryId(BigInteger.valueOf(8)); + + when(botService.getById(BigInteger.TEN)).thenReturn(bot); + when(chatDashboardQueryService.available()).thenReturn(true); + when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8)))) + .thenReturn(false); + + setFieldSilently(service, "botService", botService); + setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService); + setFieldSilently(service, "categoryPermissionService", categoryPermissionService); + setFieldSilently(service, "sysAccountService", mock(SysAccountService.class)); + + DashboardUserRankQuery query = new DashboardUserRankQuery(); + query.setRange("7d"); + query.setAssistantId(BigInteger.TEN); + service.getUserRanks(new LoginAccount(), query); + } + + /** + * 验证导出结果包含表头与账号昵称列。 + */ + @Test + public void shouldExportDashboardUserRanksWithSeparatedIdentityColumns() throws Exception { + DashboardServiceImpl service = new DashboardServiceImpl(); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + SysAccountService sysAccountService = mock(SysAccountService.class); + + when(chatDashboardQueryService.available()).thenReturn(true); + when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), isNull(), isNull())) + .thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(7), "export-user", 3L, 6L, 1L))); + when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(7L, "export-user", "导出演示"))); + + setField(service, "chatDashboardQueryService", chatDashboardQueryService); + setField(service, "sysAccountService", sysAccountService); + + DashboardUserRankQuery query = new DashboardUserRankQuery(); + query.setRange("7d"); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + service.exportUserRanks(new LoginAccount(), query, outputStream); + + try (org.apache.poi.ss.usermodel.Workbook workbook = + WorkbookFactory.create(new ByteArrayInputStream(outputStream.toByteArray()))) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0); + Assert.assertEquals(sheet.getRow(0).getCell(0).getStringCellValue(), "登录账号"); + Assert.assertEquals(sheet.getRow(0).getCell(1).getStringCellValue(), "昵称"); + Assert.assertEquals(sheet.getRow(0).getCell(2).getStringCellValue(), "会话数"); + Assert.assertEquals(sheet.getRow(0).getCell(3).getStringCellValue(), "消息数"); + Assert.assertEquals(sheet.getRow(1).getCell(0).getStringCellValue(), "export-user"); + Assert.assertEquals(sheet.getRow(1).getCell(1).getStringCellValue(), "导出演示"); + Assert.assertEquals(sheet.getRow(1).getCell(2).getStringCellValue(), "3"); + Assert.assertEquals(sheet.getRow(1).getCell(3).getStringCellValue(), "6"); + } + } + /** * 构造查询上下文。 * @@ -475,4 +607,20 @@ public class DashboardServiceImplTest { } throw new IllegalArgumentException("未找到字段: " + fieldName); } + + private void setFieldSilently(Object target, String fieldName, Object value) { + try { + setField(target, fieldName, value); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private SysAccount buildSysAccount(long id, String loginName, String nickname) { + SysAccount account = new SysAccount(); + account.setId(BigInteger.valueOf(id)); + account.setLoginName(loginName); + account.setNickname(nickname); + return account; + } } 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 98e85a5..3b32228 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 @@ -487,15 +487,17 @@ public class ChatAnalyticalDBRepository { * @param startDate 开始日期,包含 * @param endDate 结束日期,不包含 * @param tenantId 租户 ID,空表示全局 + * @param assistantId 智能体 ID,空表示全部 * @param limit 返回条数 * @return 排行列表 */ public List queryActiveUserRanks(LocalDate startDate, LocalDate endDate, BigInteger tenantId, - int limit) { + BigInteger assistantId, + Integer limit) { assertAvailable(); - int safeLimit = Math.max(limit, 1); + Integer safeLimit = limit == null ? null : Math.max(limit, 1); List args = new java.util.ArrayList<>(); StringBuilder sql = new StringBuilder(); sql.append("SELECT agg.user_id AS user_id, ") @@ -511,6 +513,10 @@ public class ChatAnalyticalDBRepository { .append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)"); args.add(startDate.toString()); args.add(endDate.toString()); + if (assistantId != null) { + sql.append(" AND agg.assistant_id = ?"); + args.add(assistantId); + } appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id"); sql.append(" GROUP BY agg.user_id") .append(") agg ") @@ -520,9 +526,11 @@ public class ChatAnalyticalDBRepository { appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); sql.append(" GROUP BY user_id") .append(") snapshot ON snapshot.user_id = agg.user_id ") - .append("ORDER BY agg.session_total DESC, agg.message_total DESC, agg.user_id ASC ") - .append("LIMIT ?"); - args.add(safeLimit); + .append("ORDER BY agg.session_total DESC, agg.message_total DESC, agg.user_id ASC "); + if (safeLimit != null) { + sql.append("LIMIT ?"); + args.add(safeLimit); + } return analyticalDBOperations.query( sql.toString(), 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 index 8ea97c2..81c7f8d 100644 --- 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 @@ -94,13 +94,15 @@ public interface ChatDashboardQueryService { * @param startDate 开始日期,包含当天 * @param endDate 结束日期,不包含当天 * @param tenantId 租户 ID,空表示全局 + * @param assistantId 智能体 ID,空表示全部 * @param limit 返回条数 * @return 活跃用户排行 */ List queryActiveUserRanks(LocalDate startDate, LocalDate endDate, BigInteger tenantId, - int limit); + BigInteger assistantId, + Integer limit); /** * 当前分析库是否可用。 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 index 851a058..3567bd3 100644 --- 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 @@ -141,6 +141,7 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService * @param startDate 开始日期,包含当天 * @param endDate 结束日期,不包含当天 * @param tenantId 租户 ID,空表示全局 + * @param assistantId 智能体 ID,空表示全部 * @param limit 返回条数 * @return 活跃用户排行 */ @@ -148,11 +149,12 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService public List queryActiveUserRanks(LocalDate startDate, LocalDate endDate, BigInteger tenantId, - int limit) { + BigInteger assistantId, + Integer limit) { if (!available()) { return Collections.emptyList(); } - return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, limit); + return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, assistantId, limit); } /** diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepositoryTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepositoryTest.java index 1c48ba4..166ec4a 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepositoryTest.java +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepositoryTest.java @@ -104,6 +104,28 @@ public class ChatAnalyticalDBRepositoryTest { Assert.assertTrue(operations.lastQuerySql.contains("(agg.assistant_id IN (?) OR agg.assistant_id IS NULL)")); } + /** + * 验证用户活跃榜支持按智能体过滤,且导出场景可不带 limit。 + */ + @Test + public void shouldSupportAssistantFilterAndUnlimitedUserRanks() { + RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations(); + + ChatAnalyticalDBRepository repository = newRepository(operations); + repository.queryActiveUserRanks( + LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 8), + BigInteger.ONE, + BigInteger.TEN, + null + ); + + Assert.assertNotNull(operations.lastQuerySql); + Assert.assertTrue(operations.lastQuerySql.contains("AND agg.assistant_id = ?")); + Assert.assertTrue(operations.lastQuerySql.contains("ORDER BY agg.session_total DESC, agg.message_total DESC, agg.user_id ASC")); + Assert.assertFalse(operations.lastQuerySql.contains("LIMIT ?")); + } + /** * 构造仓储实例。 * diff --git a/easyflow-ui-admin/app/src/api/dashboard.ts b/easyflow-ui-admin/app/src/api/dashboard.ts index ff6cdf4..2dc7047 100644 --- a/easyflow-ui-admin/app/src/api/dashboard.ts +++ b/easyflow-ui-admin/app/src/api/dashboard.ts @@ -1,4 +1,4 @@ -import { requestClient } from '#/api/request'; +import { api, requestClient } from '#/api/request'; export type DashboardRange = '7d' | '30d' | 'custom' | 'today'; @@ -8,6 +8,10 @@ export interface DashboardOverviewQuery { startDate?: string; } +export interface DashboardUserRankQuery extends DashboardOverviewQuery { + assistantId?: string; +} + export interface DashboardSummary { activeUserTotal: number; activeAssistantTotal: number; @@ -63,9 +67,10 @@ export interface DashboardDistributionItem { } export interface DashboardUserRankItem { - assistantTotal: number; label: string; + loginName?: string; messageTotal: number; + nickname?: string; sessionTotal: number; userId?: number | string; } @@ -78,7 +83,6 @@ export interface DashboardOverviewResponse { summary: DashboardSummary; trends: DashboardTrendItem[]; updatedAt: string; - userRanks: DashboardUserRankItem[]; } export async function getDashboardOverview(params: DashboardOverviewQuery) { @@ -89,3 +93,29 @@ export async function getDashboardOverview(params: DashboardOverviewQuery) { }, ); } + +export async function getDashboardUserRanks(params: DashboardUserRankQuery) { + return requestClient.get( + '/api/v1/dashboard/user-ranks', + { + params, + }, + ); +} + +export async function exportDashboardUserRanks(params: DashboardUserRankQuery) { + return api.download(buildDashboardUrl('/api/v1/dashboard/user-ranks/export', params)); +} + +function buildDashboardUrl(url: string, params?: Record) { + const base = + typeof window === 'undefined' ? 'http://localhost' : window.location.origin; + const target = new URL(url, base); + Object.entries(params || {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + return; + } + target.searchParams.set(key, String(value)); + }); + return `${target.pathname}${target.search}`; +} 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 f44ce23..860acf2 100644 --- a/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue +++ b/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue @@ -10,6 +10,7 @@ import type { DashboardSummary, DashboardTrendItem, DashboardUserRankItem, + DashboardUserRankQuery, } from '#/api/dashboard'; import { @@ -25,19 +26,26 @@ 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 { convertToRgb, downloadFileFromBlob } from '@easyflow/utils'; import { RefreshRight } from '@element-plus/icons-vue'; import { ElButton, ElDatePicker, ElEmpty, + ElOption, ElPopover, ElRadioButton, ElRadioGroup, + ElSelect, } from 'element-plus'; -import { getDashboardOverview } from '#/api/dashboard'; +import { + exportDashboardUserRanks, + getDashboardOverview, + getDashboardUserRanks, +} from '#/api/dashboard'; +import { requestClient } from '#/api/request'; type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive'; @@ -54,7 +62,13 @@ interface SummaryCardItem { value: string; } +interface AssistantOptionItem { + label: string; + value: string; +} + let greetingTimer: null | ReturnType = null; +let userRankRequestId = 0; const userStore = useUserStore(); const now = ref(new Date()); @@ -68,6 +82,15 @@ const isLoading = ref(false); const errorMessage = ref(''); const trendMode = ref('usage'); const selectedAssistantTrendKeys = ref([]); +const assistantOptions = ref([ + { label: '全部智能体', value: '' }, +]); +const assistantOptionsLoading = ref(false); +const selectedAssistantId = ref(''); +const userRankItems = ref([]); +const userRankLoading = ref(false); +const userRankExportLoading = ref(false); +const userRankErrorMessage = ref(''); const trendChartRef = ref(); const { @@ -110,9 +133,6 @@ const assistantTrends = computed( const distribution = computed( () => overview.value?.distribution ?? [], ); -const userRanks = computed( - () => overview.value?.userRanks ?? [], -); const chatAvailable = computed( () => overview.value?.chatStatus?.available !== false, ); @@ -278,22 +298,30 @@ const showAssistantTrendEmptySelection = computed( ); const showTrendChart = computed(() => chatAvailable.value); +const showUserRankLoading = computed( + () => userRankLoading.value && userRankItems.value.length === 0, +); async function loadOverview() { isLoading.value = true; errorMessage.value = ''; try { - const data = await getDashboardOverview({ - endDate: filters.range === 'custom' ? filters.endDate : undefined, - range: filters.range, - startDate: filters.range === 'custom' ? filters.startDate : undefined, - }); + const [data] = await Promise.all([ + getDashboardOverview(buildOverviewQuery()), + loadAssistantOptions().catch(() => undefined), + ]); overview.value = data; resetAssistantTrendSelection(); await renderCharts(); + if (data.chatStatus?.available === false) { + resetUserRanks(); + } else { + await loadUserRanks(); + } } catch (error) { overview.value = null; + resetUserRanks(); errorMessage.value = (error as Error)?.message || '工作台数据加载失败,请稍后重试。'; } finally { @@ -301,6 +329,110 @@ async function loadOverview() { } } +async function loadAssistantOptions() { + assistantOptionsLoading.value = true; + try { + const bots = await requestClient.get< + Array<{ id?: number | string; title?: string }> + >('/api/v1/bot/list', { + params: { status: 1 }, + }); + const nextOptions: AssistantOptionItem[] = [ + { label: '全部智能体', value: '' }, + ...(bots || []).map((item) => ({ + label: item.title?.trim() || '未命名智能体', + value: item.id === undefined || item.id === null ? '' : String(item.id), + })), + ]; + assistantOptions.value = nextOptions; + if (!nextOptions.some((item) => item.value === selectedAssistantId.value)) { + selectedAssistantId.value = ''; + } + } catch (error) { + assistantOptions.value = [{ label: '全部智能体', value: '' }]; + selectedAssistantId.value = ''; + throw error; + } finally { + assistantOptionsLoading.value = false; + } +} + +async function loadUserRanks() { + if (!overview.value || chatAvailable.value === false) { + resetUserRanks(); + return; + } + const currentRequestId = ++userRankRequestId; + userRankLoading.value = true; + userRankErrorMessage.value = ''; + userRankItems.value = []; + + try { + const data = await getDashboardUserRanks(buildUserRankQuery()); + if (currentRequestId !== userRankRequestId) { + return; + } + userRankItems.value = data || []; + } catch (error) { + if (currentRequestId !== userRankRequestId) { + return; + } + userRankItems.value = []; + userRankErrorMessage.value = + (error as Error)?.message || '用户活跃榜加载失败,请稍后重试。'; + } finally { + if (currentRequestId === userRankRequestId) { + userRankLoading.value = false; + } + } +} + +function resetUserRanks() { + userRankRequestId += 1; + userRankItems.value = []; + userRankLoading.value = false; + userRankErrorMessage.value = ''; +} + +function buildOverviewQuery(): DashboardOverviewQuery { + return { + endDate: filters.range === 'custom' ? filters.endDate : undefined, + range: filters.range, + startDate: filters.range === 'custom' ? filters.startDate : undefined, + }; +} + +function buildUserRankQuery(): DashboardUserRankQuery { + return { + ...buildOverviewQuery(), + assistantId: selectedAssistantId.value || undefined, + }; +} + +function handleUserRankAssistantChange( + value: boolean | number | string | undefined, +) { + selectedAssistantId.value = + value === undefined || value === null ? '' : String(value); + void loadUserRanks(); +} + +async function handleUserRankExport() { + if (userRankExportLoading.value) { + return; + } + userRankExportLoading.value = true; + try { + const blob = await exportDashboardUserRanks(buildUserRankQuery()); + downloadFileFromBlob({ + fileName: 'dashboard_user_ranks.xlsx', + source: blob, + }); + } finally { + userRankExportLoading.value = false; + } +} + async function renderCharts() { await nextTick(); if (!showTrendChart.value) { @@ -1057,10 +1189,58 @@ onBeforeUnmount(() => {
+