Compare commits
2 Commits
8d07b306e5
...
ff863e3c27
| Author | SHA1 | Date | |
|---|---|---|---|
| ff863e3c27 | |||
| 516d43ce7d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,6 +42,7 @@ build/
|
|||||||
.jlsp/
|
.jlsp/
|
||||||
.arts/
|
.arts/
|
||||||
luceneKnowledge
|
luceneKnowledge
|
||||||
|
**/*.lic
|
||||||
|
|
||||||
# v1
|
# v1
|
||||||
/easyflow-ui-react
|
/easyflow-ui-react
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
package tech.easyflow.admin.controller.dashboard;
|
package tech.easyflow.admin.controller.dashboard;
|
||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
|
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.admin.service.dashboard.DashboardService;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
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<DashboardOverviewVo> overview(DashboardOverviewQuery query) {
|
public Result<DashboardOverviewVo> overview(DashboardOverviewQuery query) {
|
||||||
return Result.ok(dashboardService.getOverview(SaTokenUtil.getLoginAccount(), query));
|
return Result.ok(dashboardService.getOverview(SaTokenUtil.getLoginAccount(), query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/user-ranks")
|
||||||
|
@SaCheckPermission("/api/v1/dashboard/query")
|
||||||
|
public Result<List<DashboardUserRankItemVo>> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ public class DashboardOverviewVo {
|
|||||||
|
|
||||||
private List<DashboardDistributionItemVo> distribution;
|
private List<DashboardDistributionItemVo> distribution;
|
||||||
|
|
||||||
private List<DashboardUserRankItemVo> userRanks;
|
|
||||||
|
|
||||||
private DashboardOverviewQuery query;
|
private DashboardOverviewQuery query;
|
||||||
|
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
@@ -64,14 +62,6 @@ public class DashboardOverviewVo {
|
|||||||
this.assistantTrends = assistantTrends;
|
this.assistantTrends = assistantTrends;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<DashboardUserRankItemVo> getUserRanks() {
|
|
||||||
return userRanks;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUserRanks(List<DashboardUserRankItemVo> userRanks) {
|
|
||||||
this.userRanks = userRanks;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DashboardOverviewQuery getQuery() {
|
public DashboardOverviewQuery getQuery() {
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,25 @@ public class DashboardUserRankItemVo {
|
|||||||
|
|
||||||
private BigInteger userId;
|
private BigInteger userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最终展示名称。
|
||||||
|
*/
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录账号。
|
||||||
|
*/
|
||||||
|
private String loginName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昵称。
|
||||||
|
*/
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
private Long sessionTotal;
|
private Long sessionTotal;
|
||||||
|
|
||||||
private Long messageTotal;
|
private Long messageTotal;
|
||||||
|
|
||||||
private Long assistantTotal;
|
|
||||||
|
|
||||||
public BigInteger getUserId() {
|
public BigInteger getUserId() {
|
||||||
return userId;
|
return userId;
|
||||||
}
|
}
|
||||||
@@ -33,6 +44,22 @@ public class DashboardUserRankItemVo {
|
|||||||
this.label = label;
|
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() {
|
public Long getSessionTotal() {
|
||||||
return sessionTotal;
|
return sessionTotal;
|
||||||
}
|
}
|
||||||
@@ -48,12 +75,4 @@ public class DashboardUserRankItemVo {
|
|||||||
public void setMessageTotal(Long messageTotal) {
|
public void setMessageTotal(Long messageTotal) {
|
||||||
this.messageTotal = messageTotal;
|
this.messageTotal = messageTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getAssistantTotal() {
|
|
||||||
return assistantTotal;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAssistantTotal(Long assistantTotal) {
|
|
||||||
this.assistantTotal = assistantTotal;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,42 @@ package tech.easyflow.admin.service.dashboard;
|
|||||||
|
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
|
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 tech.easyflow.common.entity.LoginAccount;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作台统计服务。
|
* 工作台统计服务。
|
||||||
*/
|
*/
|
||||||
public interface DashboardService {
|
public interface DashboardService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作台总览。
|
||||||
|
*
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @param query 查询条件
|
||||||
|
* @return 工作台总览
|
||||||
|
*/
|
||||||
DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query);
|
DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户活跃榜。
|
||||||
|
*
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @param query 查询条件
|
||||||
|
* @return 用户活跃榜
|
||||||
|
*/
|
||||||
|
List<DashboardUserRankItemVo> getUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出用户活跃榜。
|
||||||
|
*
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @param query 查询条件
|
||||||
|
* @param outputStream 输出流
|
||||||
|
*/
|
||||||
|
void exportUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query, OutputStream outputStream);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package tech.easyflow.admin.service.dashboard.impl;
|
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.query.QueryWrapper;
|
||||||
import com.mybatisflex.core.row.Db;
|
import com.mybatisflex.core.row.Db;
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.DashboardChatStatusVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
|
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
|
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.DashboardSummaryVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
|
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
|
||||||
import tech.easyflow.admin.service.dashboard.DashboardService;
|
import tech.easyflow.admin.service.dashboard.DashboardService;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
|
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
|
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.constant.Constants;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
import tech.easyflow.system.entity.SysAccountRole;
|
import tech.easyflow.system.entity.SysAccountRole;
|
||||||
import tech.easyflow.system.entity.SysRole;
|
import tech.easyflow.system.entity.SysRole;
|
||||||
|
import tech.easyflow.system.service.CategoryPermissionService;
|
||||||
import tech.easyflow.system.service.SysAccountService;
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
import tech.easyflow.system.service.SysAccountRoleService;
|
import tech.easyflow.system.service.SysAccountRoleService;
|
||||||
import tech.easyflow.system.service.SysRoleService;
|
import tech.easyflow.system.service.SysRoleService;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
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_RANK_LIMIT = 5;
|
||||||
private static final int DEFAULT_ASSISTANT_TREND_LIMIT = 8;
|
private static final int DEFAULT_ASSISTANT_TREND_LIMIT = 8;
|
||||||
private static final int DEFAULT_USER_RANK_LIMIT = 5;
|
private static final int DEFAULT_USER_RANK_LIMIT = 5;
|
||||||
|
private static final int USER_RANK_EXPORT_COLUMN_WIDTH = 20;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysAccountRoleService sysAccountRoleService;
|
private SysAccountRoleService sysAccountRoleService;
|
||||||
@@ -71,6 +80,12 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
@Resource
|
@Resource
|
||||||
private ChatDashboardQueryService chatDashboardQueryService;
|
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.setTrends(chatPayload.trends);
|
||||||
result.setAssistantTrends(chatPayload.assistantTrends);
|
result.setAssistantTrends(chatPayload.assistantTrends);
|
||||||
result.setDistribution(chatPayload.distribution);
|
result.setDistribution(chatPayload.distribution);
|
||||||
result.setUserRanks(chatPayload.userRanks);
|
|
||||||
|
|
||||||
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
|
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
|
||||||
normalizedQuery.setRange(context.range);
|
normalizedQuery.setRange(context.range);
|
||||||
@@ -101,6 +115,37 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户活跃榜。
|
||||||
|
*
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @param query 查询条件
|
||||||
|
* @return 用户活跃榜
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<DashboardUserRankItemVo> 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<DashboardUserRankItemVo> 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,
|
chatStatus,
|
||||||
new ArrayList<>(),
|
new ArrayList<>(),
|
||||||
new ArrayList<>(),
|
new ArrayList<>(),
|
||||||
new ArrayList<>(),
|
|
||||||
new ArrayList<>()
|
new ArrayList<>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -176,14 +220,7 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(
|
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(
|
||||||
rawRanks.subList(0, Math.min(DEFAULT_ASSISTANT_RANK_LIMIT, rawRanks.size()))
|
rawRanks.subList(0, Math.min(DEFAULT_ASSISTANT_RANK_LIMIT, rawRanks.size()))
|
||||||
);
|
);
|
||||||
List<ChatActiveUserRank> rawUserRanks = chatDashboardQueryService.queryActiveUserRanks(
|
return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution);
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
context.tenantFilterId,
|
|
||||||
DEFAULT_USER_RANK_LIMIT
|
|
||||||
);
|
|
||||||
List<DashboardUserRankItemVo> userRanks = buildUserRanks(rawUserRanks);
|
|
||||||
return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution, userRanks);
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}",
|
log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}",
|
||||||
context.range,
|
context.range,
|
||||||
@@ -199,7 +236,6 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
chatStatus,
|
chatStatus,
|
||||||
new ArrayList<>(),
|
new ArrayList<>(),
|
||||||
new ArrayList<>(),
|
new ArrayList<>(),
|
||||||
new ArrayList<>(),
|
|
||||||
new ArrayList<>()
|
new ArrayList<>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -328,6 +364,27 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return seriesList;
|
return seriesList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户活跃排行。
|
||||||
|
*
|
||||||
|
* @param context 查询上下文
|
||||||
|
* @param limit 返回条数,空表示全部
|
||||||
|
* @return 用户活跃排行
|
||||||
|
*/
|
||||||
|
private List<DashboardUserRankItemVo> queryUserRanks(DashboardQueryContext context, Integer limit) {
|
||||||
|
if (!chatDashboardQueryService.available()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<ChatActiveUserRank> 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 页面排行项
|
* @return 页面排行项
|
||||||
*/
|
*/
|
||||||
private List<DashboardUserRankItemVo> buildUserRanks(List<ChatActiveUserRank> ranks) {
|
private List<DashboardUserRankItemVo> buildUserRanks(List<ChatActiveUserRank> ranks) {
|
||||||
|
if (ranks == null || ranks.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
List<DashboardUserRankItemVo> items = new ArrayList<>(ranks.size());
|
List<DashboardUserRankItemVo> items = new ArrayList<>(ranks.size());
|
||||||
Map<BigInteger, String> displayNameMap = resolveUserDisplayNameMap(ranks);
|
Map<BigInteger, AccountIdentitySnapshot> identityMap = resolveAccountIdentityMap(ranks);
|
||||||
for (ChatActiveUserRank rank : ranks) {
|
for (ChatActiveUserRank rank : ranks) {
|
||||||
|
ResolvedUserIdentity identity = resolveUserIdentity(
|
||||||
|
rank.userId(),
|
||||||
|
rank.userAccount(),
|
||||||
|
identityMap.get(rank.userId())
|
||||||
|
);
|
||||||
DashboardUserRankItemVo item = new DashboardUserRankItemVo();
|
DashboardUserRankItemVo item = new DashboardUserRankItemVo();
|
||||||
item.setUserId(rank.userId());
|
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.setSessionTotal(rank.sessionTotal());
|
||||||
item.setMessageTotal(rank.messageTotal());
|
item.setMessageTotal(rank.messageTotal());
|
||||||
item.setAssistantTotal(rank.assistantTotal());
|
|
||||||
items.add(item);
|
items.add(item);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建导出表头。
|
||||||
|
*
|
||||||
|
* @return 表头
|
||||||
|
*/
|
||||||
|
private List<List<String>> buildUserRankExportHead() {
|
||||||
|
List<List<String>> 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<List<String>> buildUserRankExportRows(List<DashboardUserRankItemVo> userRanks) {
|
||||||
|
List<List<String>> rows = new ArrayList<>(userRanks.size());
|
||||||
|
for (DashboardUserRankItemVo item : userRanks) {
|
||||||
|
List<String> 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 查询上下文
|
* @return 查询上下文
|
||||||
*/
|
*/
|
||||||
private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardOverviewQuery query) {
|
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();
|
DashboardQueryContext context = new DashboardQueryContext();
|
||||||
context.range = normalizeRange(query == null ? null : query.getRange());
|
context.range = normalizeRange(range);
|
||||||
context.superAdmin = isSuperAdmin(loginAccount);
|
context.superAdmin = isSuperAdmin(loginAccount);
|
||||||
|
|
||||||
LocalDate today = LocalDate.now(DEFAULT_ZONE_ID);
|
LocalDate today = LocalDate.now(DEFAULT_ZONE_ID);
|
||||||
@@ -463,8 +601,8 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
context.queryStartDate = today.minusDays(29).toString();
|
context.queryStartDate = today.minusDays(29).toString();
|
||||||
context.queryEndDate = today.toString();
|
context.queryEndDate = today.toString();
|
||||||
} else {
|
} else {
|
||||||
LocalDate customStartDate = parseRequiredDate(query == null ? null : query.getStartDate(), "开始日期不能为空");
|
LocalDate customStartDate = parseRequiredDate(startDate, "开始日期不能为空");
|
||||||
LocalDate customEndDate = parseRequiredDate(query == null ? null : query.getEndDate(), "结束日期不能为空");
|
LocalDate customEndDate = parseRequiredDate(endDate, "结束日期不能为空");
|
||||||
if (customStartDate.isAfter(customEndDate)) {
|
if (customStartDate.isAfter(customEndDate)) {
|
||||||
throw new BusinessException("开始日期不能晚于结束日期");
|
throw new BusinessException("开始日期不能晚于结束日期");
|
||||||
}
|
}
|
||||||
@@ -645,7 +783,7 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
* @param ranks 活跃排行
|
* @param ranks 活跃排行
|
||||||
* @return 名称映射
|
* @return 名称映射
|
||||||
*/
|
*/
|
||||||
private Map<BigInteger, String> resolveUserDisplayNameMap(List<ChatActiveUserRank> ranks) {
|
private Map<BigInteger, AccountIdentitySnapshot> resolveAccountIdentityMap(List<ChatActiveUserRank> ranks) {
|
||||||
List<BigInteger> userIds = ranks.stream()
|
List<BigInteger> userIds = ranks.stream()
|
||||||
.map(ChatActiveUserRank::userId)
|
.map(ChatActiveUserRank::userId)
|
||||||
.filter(java.util.Objects::nonNull)
|
.filter(java.util.Objects::nonNull)
|
||||||
@@ -654,7 +792,24 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
if (userIds.isEmpty()) {
|
if (userIds.isEmpty()) {
|
||||||
return new HashMap<>();
|
return new HashMap<>();
|
||||||
}
|
}
|
||||||
return sysAccountService.resolveDisplayNameMap(userIds);
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
|
.select(SysAccount::getId, SysAccount::getLoginName, SysAccount::getNickname)
|
||||||
|
.in(SysAccount::getId, userIds);
|
||||||
|
List<SysAccount> accounts = sysAccountService.list(queryWrapper);
|
||||||
|
Map<BigInteger, AccountIdentitySnapshot> 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 userId 用户 ID
|
||||||
* @param userAccount 聊天侧账号快照
|
* @param userAccount 聊天侧账号快照
|
||||||
* @param displayNameMap 系统账号名称映射
|
* @param snapshot 系统账号快照
|
||||||
* @return 展示名称
|
* @return 用户身份
|
||||||
*/
|
*/
|
||||||
private String resolveUserLabel(BigInteger userId, String userAccount, Map<BigInteger, String> displayNameMap) {
|
private ResolvedUserIdentity resolveUserIdentity(BigInteger userId,
|
||||||
if (userId != null) {
|
String userAccount,
|
||||||
String displayName = displayNameMap.get(userId);
|
AccountIdentitySnapshot snapshot) {
|
||||||
if (StringUtils.hasText(displayName) && !displayName.equals(userId.toString())) {
|
String loginName = snapshot == null ? null : snapshot.loginName;
|
||||||
return displayName;
|
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)) {
|
if (StringUtils.hasText(trimmedUserAccount)) {
|
||||||
return userAccount.trim();
|
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 String range;
|
||||||
private BigInteger tenantFilterId;
|
private BigInteger tenantFilterId;
|
||||||
private BigInteger deptFilterId;
|
private BigInteger deptFilterId;
|
||||||
|
private BigInteger assistantId;
|
||||||
private boolean superAdmin;
|
private boolean superAdmin;
|
||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
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,
|
DashboardChatStatusVo chatStatus,
|
||||||
List<DashboardTrendItemVo> trends,
|
List<DashboardTrendItemVo> trends,
|
||||||
List<DashboardAssistantTrendSeriesVo> assistantTrends,
|
List<DashboardAssistantTrendSeriesVo> assistantTrends,
|
||||||
List<DashboardDistributionItemVo> distribution,
|
List<DashboardDistributionItemVo> distribution
|
||||||
List<DashboardUserRankItemVo> userRanks
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package tech.easyflow.admin.service.dashboard.impl;
|
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.Assert;
|
||||||
import org.testng.annotations.Test;
|
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.DashboardAssistantTrendSeriesVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
|
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.ChatActiveUserRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
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.domain.dto.ChatDashboardTrend;
|
||||||
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
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.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.Constructor;
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -25,13 +37,15 @@ import java.time.LocalDate;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.stream.IntStream;
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -71,7 +85,7 @@ public class DashboardServiceImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 today 返回 24 个小时点位,且排行名称与均值回退正确。
|
* 验证 today 返回 24 个小时点位,且 overview 不再触发用户榜查询。
|
||||||
*
|
*
|
||||||
* @throws Exception 反射调用失败
|
* @throws Exception 反射调用失败
|
||||||
*/
|
*/
|
||||||
@@ -91,12 +105,7 @@ public class DashboardServiceImplTest {
|
|||||||
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 2L, 3L, 9L)));
|
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 2L, 3L, 9L)));
|
||||||
when(chatDashboardQueryService.queryAssistantHourlyTrends(any(), any(), any(), any()))
|
when(chatDashboardQueryService.queryAssistantHourlyTrends(any(), any(), any(), any()))
|
||||||
.thenReturn(List.of(new ChatAssistantSessionTrend(BigInteger.ONE, "", currentHourKey, 3L)));
|
.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, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
setField(service, "sysAccountService", sysAccountService);
|
|
||||||
|
|
||||||
Object context = newContext("today", BigInteger.valueOf(9));
|
Object context = newContext("today", BigInteger.valueOf(9));
|
||||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
@@ -107,7 +116,6 @@ public class DashboardServiceImplTest {
|
|||||||
List<DashboardAssistantTrendSeriesVo> assistantTrends =
|
List<DashboardAssistantTrendSeriesVo> assistantTrends =
|
||||||
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
|
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
|
||||||
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
|
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
|
||||||
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
|
|
||||||
|
|
||||||
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
||||||
Assert.assertEquals(trends.size(), 24);
|
Assert.assertEquals(trends.size(), 24);
|
||||||
@@ -132,40 +140,31 @@ public class DashboardServiceImplTest {
|
|||||||
Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1");
|
Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1");
|
||||||
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
|
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
|
||||||
Assert.assertEquals(distribution.get(0).getAvgSessionPerUser(), Double.valueOf(1.5D));
|
Assert.assertEquals(distribution.get(0).getAvgSessionPerUser(), Double.valueOf(1.5D));
|
||||||
Assert.assertEquals(userRanks.get(0).getLabel(), "演示用户");
|
verify(chatDashboardQueryService, never()).queryActiveUserRanks(any(), any(), any(), any(), any());
|
||||||
Assert.assertEquals(userRanks.get(0).getAssistantTotal(), Long.valueOf(1L));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证当系统账号名称仅回退为纯 ID 时,仍优先继续回退到聊天侧账号。
|
* 验证当系统账号名称仅回退为纯 ID 时,仍优先继续回退到聊天侧账号。
|
||||||
*
|
|
||||||
* @throws Exception 反射调用失败
|
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public void shouldFallbackToUserAccountWhenSystemDisplayNameIsOnlyId() throws Exception {
|
public void shouldFallbackToUserAccountWhenSystemDisplayNameIsOnlyId() throws Exception {
|
||||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||||
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||||
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), 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)))
|
|
||||||
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(9), "chat-user", 1L, 1L, 1L)));
|
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(9), "chat-user", 1L, 1L, 1L)));
|
||||||
SysAccountService sysAccountService = mock(SysAccountService.class);
|
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, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
setField(service, "sysAccountService", sysAccountService);
|
setField(service, "sysAccountService", sysAccountService);
|
||||||
|
|
||||||
Object context = newContext("today", BigInteger.ONE);
|
DashboardUserRankQuery query = new DashboardUserRankQuery();
|
||||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
query.setRange("7d");
|
||||||
Object payload = invokeBuildChatPayload(service, context, summary);
|
|
||||||
|
|
||||||
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
|
List<DashboardUserRankItemVo> userRanks = service.getUserRanks(new LoginAccount(), query);
|
||||||
Assert.assertEquals(userRanks.get(0).getLabel(), "chat-user");
|
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(BigInteger.ONE, "助手-A", LocalDate.now().toString(), 4L),
|
||||||
new ChatAssistantSessionTrend(null, "未知助手", LocalDate.now().minusDays(3).toString(), 2L)
|
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, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
setField(service, "sysAccountService", sysAccountService);
|
|
||||||
|
|
||||||
Object context = newContext("7d", BigInteger.ONE);
|
Object context = newContext("7d", BigInteger.ONE);
|
||||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
@@ -238,10 +232,7 @@ public class DashboardServiceImplTest {
|
|||||||
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 2L, 6L, 1L)));
|
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 2L, 6L, 1L)));
|
||||||
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
|
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
|
|
||||||
.thenReturn(List.of());
|
|
||||||
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
setField(service, "sysAccountService", mock(SysAccountService.class));
|
|
||||||
|
|
||||||
Object context = newContext(
|
Object context = newContext(
|
||||||
"custom",
|
"custom",
|
||||||
@@ -321,10 +312,7 @@ public class DashboardServiceImplTest {
|
|||||||
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", startDate.toString(), 2L),
|
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", startDate.toString(), 2L),
|
||||||
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", endDate.toString(), 4L)
|
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, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
setField(service, "sysAccountService", mock(SysAccountService.class));
|
|
||||||
|
|
||||||
Object context = newContext("30d", BigInteger.ONE);
|
Object context = newContext("30d", BigInteger.ONE);
|
||||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
@@ -344,6 +332,150 @@ public class DashboardServiceImplTest {
|
|||||||
verify(chatDashboardQueryService).queryAssistantUsageRanks(any(), any(), any(), eq(8));
|
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<DashboardUserRankItemVo> 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);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,11 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-common-web</artifactId>
|
<artifactId>easyflow-common-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.BeansException;
|
||||||
|
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
|
||||||
|
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
|
||||||
|
import org.springframework.context.EnvironmentAware;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.PriorityOrdered;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.io.DefaultResourceLoader;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EasyFlow 启动前的 license 强制校验器。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class EasyflowLicenseBootstrapValidator implements BeanFactoryPostProcessor, EnvironmentAware, PriorityOrdered {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(EasyflowLicenseBootstrapValidator.class);
|
||||||
|
|
||||||
|
private final EasyflowLicenseVerifier easyflowLicenseVerifier;
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造启动前校验器。
|
||||||
|
*/
|
||||||
|
public EasyflowLicenseBootstrapValidator() {
|
||||||
|
this(new EasyflowLicenseVerifier(new DefaultResourceLoader(), new MachineIdentityCollector()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造启动前校验器。
|
||||||
|
*
|
||||||
|
* @param easyflowLicenseVerifier license 校验器
|
||||||
|
*/
|
||||||
|
EasyflowLicenseBootstrapValidator(EasyflowLicenseVerifier easyflowLicenseVerifier) {
|
||||||
|
this.easyflowLicenseVerifier = easyflowLicenseVerifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从环境中读取 license 配置。
|
||||||
|
*
|
||||||
|
* @param environment Spring 环境
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void setEnvironment(Environment environment) {
|
||||||
|
this.location = environment.getProperty("easyflow.license.location");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 BeanFactory 初始化阶段执行 license 校验。
|
||||||
|
*
|
||||||
|
* @param beanFactory BeanFactory
|
||||||
|
* @throws BeansException 校验失败
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
|
||||||
|
try {
|
||||||
|
EasyflowLicenseVerificationResult result = easyflowLicenseVerifier.verify(location);
|
||||||
|
LOG.info("license 校验通过: location={}, licenseId={}, keyId={}, licenseType={}, expiresAt={}",
|
||||||
|
result.location(),
|
||||||
|
result.licenseId(),
|
||||||
|
result.keyId(),
|
||||||
|
result.licenseType(),
|
||||||
|
result.expiresAt());
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
if (ex.isMissing()) {
|
||||||
|
LOG.error("lic 文件不存在: location={}, reason={}",
|
||||||
|
ex.getLocation(),
|
||||||
|
ex.getMessage(),
|
||||||
|
ex);
|
||||||
|
} else {
|
||||||
|
LOG.error("license 文件校验失败: location={}, licenseId={}, keyId={}, reason={}",
|
||||||
|
ex.getLocation(),
|
||||||
|
ex.getLicenseId(),
|
||||||
|
ex.getKeyId(),
|
||||||
|
ex.getMessage(),
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以最高优先级执行,先于其他启动扩展。
|
||||||
|
*
|
||||||
|
* @return 执行顺序
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int getOrder() {
|
||||||
|
return Ordered.HIGHEST_PRECEDENCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EasyFlow license 校验异常。
|
||||||
|
*/
|
||||||
|
public class EasyflowLicenseException extends RuntimeException {
|
||||||
|
|
||||||
|
private final Reason reason;
|
||||||
|
private final String location;
|
||||||
|
private final String licenseId;
|
||||||
|
private final String keyId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造 license 校验异常。
|
||||||
|
*
|
||||||
|
* @param reason 异常类型
|
||||||
|
* @param message 异常消息
|
||||||
|
* @param location license 位置
|
||||||
|
* @param licenseId licenseId
|
||||||
|
* @param keyId keyId
|
||||||
|
* @param cause 原始异常
|
||||||
|
*/
|
||||||
|
public EasyflowLicenseException(Reason reason,
|
||||||
|
String message,
|
||||||
|
String location,
|
||||||
|
String licenseId,
|
||||||
|
String keyId,
|
||||||
|
Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.reason = reason;
|
||||||
|
this.location = location;
|
||||||
|
this.licenseId = licenseId;
|
||||||
|
this.keyId = keyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建缺失类异常。
|
||||||
|
*
|
||||||
|
* @param message 异常消息
|
||||||
|
* @param location license 位置
|
||||||
|
* @return 异常实例
|
||||||
|
*/
|
||||||
|
public static EasyflowLicenseException missing(String message, String location) {
|
||||||
|
return new EasyflowLicenseException(Reason.MISSING, message, location, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建缺失类异常。
|
||||||
|
*
|
||||||
|
* @param message 异常消息
|
||||||
|
* @param location license 位置
|
||||||
|
* @param cause 原始异常
|
||||||
|
* @return 异常实例
|
||||||
|
*/
|
||||||
|
public static EasyflowLicenseException missing(String message, String location, Throwable cause) {
|
||||||
|
return new EasyflowLicenseException(Reason.MISSING, message, location, null, null, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建校验失败异常。
|
||||||
|
*
|
||||||
|
* @param message 异常消息
|
||||||
|
* @param location license 位置
|
||||||
|
* @param licenseId licenseId
|
||||||
|
* @param keyId keyId
|
||||||
|
* @return 异常实例
|
||||||
|
*/
|
||||||
|
public static EasyflowLicenseException invalid(String message, String location, String licenseId, String keyId) {
|
||||||
|
return new EasyflowLicenseException(Reason.INVALID, message, location, licenseId, keyId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建校验失败异常。
|
||||||
|
*
|
||||||
|
* @param message 异常消息
|
||||||
|
* @param location license 位置
|
||||||
|
* @param licenseId licenseId
|
||||||
|
* @param keyId keyId
|
||||||
|
* @param cause 原始异常
|
||||||
|
* @return 异常实例
|
||||||
|
*/
|
||||||
|
public static EasyflowLicenseException invalid(String message,
|
||||||
|
String location,
|
||||||
|
String licenseId,
|
||||||
|
String keyId,
|
||||||
|
Throwable cause) {
|
||||||
|
return new EasyflowLicenseException(Reason.INVALID, message, location, licenseId, keyId, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取异常类型。
|
||||||
|
*
|
||||||
|
* @return 异常类型
|
||||||
|
*/
|
||||||
|
public Reason getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 license 位置。
|
||||||
|
*
|
||||||
|
* @return license 位置
|
||||||
|
*/
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 licenseId。
|
||||||
|
*
|
||||||
|
* @return licenseId
|
||||||
|
*/
|
||||||
|
public String getLicenseId() {
|
||||||
|
return licenseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 keyId。
|
||||||
|
*
|
||||||
|
* @return keyId
|
||||||
|
*/
|
||||||
|
public String getKeyId() {
|
||||||
|
return keyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为缺失类异常。
|
||||||
|
*
|
||||||
|
* @return 是否缺失
|
||||||
|
*/
|
||||||
|
public boolean isMissing() {
|
||||||
|
return reason == Reason.MISSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* License 异常类型。
|
||||||
|
*/
|
||||||
|
public enum Reason {
|
||||||
|
MISSING,
|
||||||
|
INVALID
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EasyFlow license 配置属性。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.license")
|
||||||
|
public class EasyflowLicenseProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* License 资源位置,支持 classpath: 与 file: 形式。
|
||||||
|
*/
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 license 资源位置。
|
||||||
|
*
|
||||||
|
* @return license 资源位置
|
||||||
|
*/
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 license 资源位置。
|
||||||
|
*
|
||||||
|
* @param location license 资源位置
|
||||||
|
*/
|
||||||
|
public void setLocation(String location) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* License 校验通过后的关键信息。
|
||||||
|
*
|
||||||
|
* @param location license 位置
|
||||||
|
* @param licenseId licenseId
|
||||||
|
* @param keyId keyId
|
||||||
|
* @param licenseType license 类型
|
||||||
|
* @param expiresAt 过期时间
|
||||||
|
*/
|
||||||
|
public record EasyflowLicenseVerificationResult(String location,
|
||||||
|
String licenseId,
|
||||||
|
String keyId,
|
||||||
|
String licenseType,
|
||||||
|
String expiresAt) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.ResourceLoader;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import tech.easyflow.common.util.HashUtil;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EasyFlow license 校验器。
|
||||||
|
*/
|
||||||
|
public class EasyflowLicenseVerifier {
|
||||||
|
|
||||||
|
static final String LICENSE_FILE_PREFIX = "ELIC1.";
|
||||||
|
static final String PRODUCT_NAME = "EasyFlow";
|
||||||
|
static final String SIGNATURE_ALGORITHM = "Ed25519";
|
||||||
|
static final String FINGERPRINT_ALGORITHM = "sha256(machineId|productUuid|sortedMacs)";
|
||||||
|
static final String DEFAULT_PUBLIC_KEY_LOCATION = "classpath:easy-license-public.pem";
|
||||||
|
|
||||||
|
private static final Set<String> SUPPORTED_LICENSE_TYPES = Set.of("TRIAL", "STANDARD", "DEV");
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private final ResourceLoader resourceLoader;
|
||||||
|
private final MachineIdentityCollector machineIdentityCollector;
|
||||||
|
private final Clock clock;
|
||||||
|
private final String publicKeyLocation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造校验器。
|
||||||
|
*
|
||||||
|
* @param resourceLoader Spring 资源加载器
|
||||||
|
* @param machineIdentityCollector 机器信息采集器
|
||||||
|
*/
|
||||||
|
public EasyflowLicenseVerifier(ResourceLoader resourceLoader, MachineIdentityCollector machineIdentityCollector) {
|
||||||
|
this(resourceLoader, machineIdentityCollector, Clock.systemUTC(), DEFAULT_PUBLIC_KEY_LOCATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造校验器。
|
||||||
|
*
|
||||||
|
* @param resourceLoader Spring 资源加载器
|
||||||
|
* @param machineIdentityCollector 机器信息采集器
|
||||||
|
* @param clock 时间时钟
|
||||||
|
* @param publicKeyLocation 公钥位置
|
||||||
|
*/
|
||||||
|
EasyflowLicenseVerifier(ResourceLoader resourceLoader,
|
||||||
|
MachineIdentityCollector machineIdentityCollector,
|
||||||
|
Clock clock,
|
||||||
|
String publicKeyLocation) {
|
||||||
|
this.resourceLoader = resourceLoader;
|
||||||
|
this.machineIdentityCollector = machineIdentityCollector;
|
||||||
|
this.clock = clock;
|
||||||
|
this.publicKeyLocation = publicKeyLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验指定位置的 license。
|
||||||
|
*
|
||||||
|
* @param location license 位置
|
||||||
|
* @return 校验结果
|
||||||
|
*/
|
||||||
|
public EasyflowLicenseVerificationResult verify(String location) {
|
||||||
|
if (!StringUtils.hasText(location)) {
|
||||||
|
throw EasyflowLicenseException.missing("未配置 easyflow.license.location", location);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ResolvedLicenseResource resolved = resolveLicenseResource(location);
|
||||||
|
String content = readText(resolved.resource());
|
||||||
|
return verifyContent(resolved.actualLocation(), content);
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
throw ex;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw EasyflowLicenseException.missing("lic 文件读取失败", location, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 license 文本内容。
|
||||||
|
*
|
||||||
|
* @param location license 位置
|
||||||
|
* @param content license 文本
|
||||||
|
* @return 校验结果
|
||||||
|
*/
|
||||||
|
EasyflowLicenseVerificationResult verifyContent(String location, String content) {
|
||||||
|
String normalizedContent = normalizeRequired(content, "licenseContent", location, null, null).trim();
|
||||||
|
if (!normalizedContent.startsWith(LICENSE_FILE_PREFIX)) {
|
||||||
|
throw EasyflowLicenseException.invalid("license 前缀非法", location, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode envelopeNode = decodeEnvelope(normalizedContent, location);
|
||||||
|
String keyId = requireText(envelopeNode, "keyId", location, null, null);
|
||||||
|
JsonNode payloadNode = requireNode(envelopeNode, "payload", location, null, keyId);
|
||||||
|
String licenseId = requireText(payloadNode, "licenseId", location, null, keyId);
|
||||||
|
|
||||||
|
validateEnvelope(envelopeNode, location, licenseId, keyId);
|
||||||
|
validatePayload(payloadNode, location, licenseId, keyId);
|
||||||
|
verifySignature(payloadNode, requireText(envelopeNode, "signature", location, licenseId, keyId), location, licenseId, keyId);
|
||||||
|
verifyFingerprint(payloadNode, location, licenseId, keyId);
|
||||||
|
|
||||||
|
String licenseType = requireText(payloadNode, "licenseType", location, licenseId, keyId);
|
||||||
|
String expiresAt = payloadNode.path("expiresAt").isNull() ? null : payloadNode.path("expiresAt").asText(null);
|
||||||
|
return new EasyflowLicenseVerificationResult(location, licenseId, keyId, licenseType, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建机器指纹材料。
|
||||||
|
*
|
||||||
|
* @param machineId machineId
|
||||||
|
* @param productUuid productUuid
|
||||||
|
* @param macAddresses MAC 地址文本
|
||||||
|
* @return 指纹材料
|
||||||
|
*/
|
||||||
|
static String buildMachineFingerprintMaterial(String machineId, String productUuid, String macAddresses) {
|
||||||
|
String normalizedMachineId = normalizeMachineId(machineId);
|
||||||
|
String normalizedProductUuid = normalizeProductUuid(productUuid);
|
||||||
|
List<String> normalizedMacs = normalizeMacAddresses(macAddresses);
|
||||||
|
return "machineId=" + normalizedMachineId
|
||||||
|
+ "|productUuid=" + normalizedProductUuid
|
||||||
|
+ "|sortedMacs=" + String.join(",", normalizedMacs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算机器指纹。
|
||||||
|
*
|
||||||
|
* @param identity 机器信息
|
||||||
|
* @return 机器指纹
|
||||||
|
*/
|
||||||
|
static String computeMachineFingerprint(MachineIdentity identity) {
|
||||||
|
return HashUtil.sha256(buildMachineFingerprintMaterial(identity.machineId(), identity.productUuid(), identity.macAddresses()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化 MAC 地址文本列表。
|
||||||
|
*
|
||||||
|
* @param macAddresses MAC 地址文本
|
||||||
|
* @return 归一化后的 MAC 地址列表
|
||||||
|
*/
|
||||||
|
static List<String> normalizeMacAddresses(String macAddresses) {
|
||||||
|
String normalized = normalizeRequired(macAddresses, "macAddresses", null, null, null);
|
||||||
|
String[] parts = normalized.split("[,\\n]");
|
||||||
|
LinkedHashSet<String> distinct = new LinkedHashSet<>();
|
||||||
|
for (String part : parts) {
|
||||||
|
if (StringUtils.hasText(part)) {
|
||||||
|
distinct.add(normalizeMacAddress(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (distinct.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("macAddresses 不能为空");
|
||||||
|
}
|
||||||
|
List<String> sorted = new ArrayList<>(distinct);
|
||||||
|
sorted.sort(Comparator.naturalOrder());
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化单个 MAC 地址。
|
||||||
|
*
|
||||||
|
* @param macAddress MAC 地址
|
||||||
|
* @return 归一化后的 MAC 地址
|
||||||
|
*/
|
||||||
|
static String normalizeMacAddress(String macAddress) {
|
||||||
|
String compact = normalizeRequired(macAddress, "macAddress", null, null, null)
|
||||||
|
.toLowerCase(Locale.ROOT)
|
||||||
|
.replaceAll("[^a-f0-9]", "");
|
||||||
|
if (compact.length() != 12) {
|
||||||
|
throw new IllegalArgumentException("MAC 地址非法: " + macAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder builder = new StringBuilder(17);
|
||||||
|
for (int index = 0; index < compact.length(); index += 2) {
|
||||||
|
if (index > 0) {
|
||||||
|
builder.append(':');
|
||||||
|
}
|
||||||
|
builder.append(compact, index, index + 2);
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化 machineId。
|
||||||
|
*
|
||||||
|
* @param machineId machineId
|
||||||
|
* @return 归一化结果
|
||||||
|
*/
|
||||||
|
static String normalizeMachineId(String machineId) {
|
||||||
|
return normalizeRequired(machineId, "machineId", null, null, null).toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化 productUuid。
|
||||||
|
*
|
||||||
|
* @param productUuid productUuid
|
||||||
|
* @return 归一化结果
|
||||||
|
*/
|
||||||
|
static String normalizeProductUuid(String productUuid) {
|
||||||
|
return normalizeRequired(productUuid, "productUuid", null, null, null).toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode decodeEnvelope(String content, String location) {
|
||||||
|
try {
|
||||||
|
String encoded = content.substring(LICENSE_FILE_PREFIX.length());
|
||||||
|
byte[] decodedBytes = Base64.getUrlDecoder().decode(encoded);
|
||||||
|
return OBJECT_MAPPER.readTree(decodedBytes);
|
||||||
|
} catch (IllegalArgumentException | IOException e) {
|
||||||
|
throw EasyflowLicenseException.invalid("license 内容解码失败", location, null, null, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateEnvelope(JsonNode envelopeNode, String location, String licenseId, String keyId) {
|
||||||
|
int formatVersion = envelopeNode.path("formatVersion").asInt(Integer.MIN_VALUE);
|
||||||
|
if (formatVersion != 1) {
|
||||||
|
throw EasyflowLicenseException.invalid("formatVersion 非法", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
String algorithm = requireText(envelopeNode, "alg", location, licenseId, keyId);
|
||||||
|
if (!SIGNATURE_ALGORITHM.equals(algorithm)) {
|
||||||
|
throw EasyflowLicenseException.invalid("签名算法非法", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
requireText(envelopeNode, "signature", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validatePayload(JsonNode payloadNode, String location, String licenseId, String keyId) {
|
||||||
|
String product = requireText(payloadNode, "product", location, licenseId, keyId);
|
||||||
|
if (!PRODUCT_NAME.equals(product)) {
|
||||||
|
throw EasyflowLicenseException.invalid("product 非法", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
String licenseType = requireText(payloadNode, "licenseType", location, licenseId, keyId);
|
||||||
|
if (!SUPPORTED_LICENSE_TYPES.contains(licenseType)) {
|
||||||
|
throw EasyflowLicenseException.invalid("licenseType 非法", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
String fingerprintAlgorithm = requireText(payloadNode, "fingerprintAlgorithm", location, licenseId, keyId);
|
||||||
|
if (!FINGERPRINT_ALGORITHM.equals(fingerprintAlgorithm)) {
|
||||||
|
throw EasyflowLicenseException.invalid("fingerprintAlgorithm 非法", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
requireText(payloadNode, "customerName", location, licenseId, keyId);
|
||||||
|
requireText(payloadNode, "machineFingerprint", location, licenseId, keyId);
|
||||||
|
|
||||||
|
parseInstant(requireText(payloadNode, "issuedAt", location, licenseId, keyId), "issuedAt", location, licenseId, keyId);
|
||||||
|
|
||||||
|
JsonNode expiresAtNode = payloadNode.path("expiresAt");
|
||||||
|
if ("DEV".equals(licenseType)) {
|
||||||
|
if (!expiresAtNode.isMissingNode() && !expiresAtNode.isNull()) {
|
||||||
|
throw EasyflowLicenseException.invalid("DEV license 不允许 expiresAt", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAtNode.isMissingNode() || expiresAtNode.isNull()) {
|
||||||
|
throw EasyflowLicenseException.invalid("非 DEV license 必须提供 expiresAt", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Instant expiresAt = parseInstant(expiresAtNode.asText(), "expiresAt", location, licenseId, keyId);
|
||||||
|
if (Instant.now(clock).isAfter(expiresAt)) {
|
||||||
|
throw EasyflowLicenseException.invalid("license 已过期", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifySignature(JsonNode payloadNode, String signatureValue, String location, String licenseId, String keyId) {
|
||||||
|
try {
|
||||||
|
PublicKey publicKey = loadPublicKey();
|
||||||
|
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
|
||||||
|
signature.initVerify(publicKey);
|
||||||
|
signature.update(LicenseCanonicalJsonSerializer.serialize(payloadNode).getBytes(StandardCharsets.UTF_8));
|
||||||
|
boolean valid = signature.verify(Base64.getDecoder().decode(signatureValue));
|
||||||
|
if (!valid) {
|
||||||
|
throw EasyflowLicenseException.invalid("license 签名校验失败", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
throw ex;
|
||||||
|
} catch (GeneralSecurityException | IOException | IllegalArgumentException e) {
|
||||||
|
throw EasyflowLicenseException.invalid("license 签名校验失败", location, licenseId, keyId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyFingerprint(JsonNode payloadNode, String location, String licenseId, String keyId) {
|
||||||
|
String expectedFingerprint = requireText(payloadNode, "machineFingerprint", location, licenseId, keyId);
|
||||||
|
MachineIdentity identity;
|
||||||
|
try {
|
||||||
|
identity = machineIdentityCollector.collect();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw EasyflowLicenseException.invalid("机器参数采集失败: " + e.getMessage(), location, licenseId, keyId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
String actualFingerprint;
|
||||||
|
try {
|
||||||
|
actualFingerprint = computeMachineFingerprint(identity);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw EasyflowLicenseException.invalid("机器指纹计算失败: " + e.getMessage(), location, licenseId, keyId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedFingerprint.equals(actualFingerprint)) {
|
||||||
|
throw EasyflowLicenseException.invalid("license 机器指纹不匹配", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublicKey loadPublicKey() throws GeneralSecurityException, IOException {
|
||||||
|
Resource publicKeyResource = resourceLoader.getResource(publicKeyLocation);
|
||||||
|
if (!publicKeyResource.exists() || !publicKeyResource.isReadable()) {
|
||||||
|
throw new IOException("公钥资源不存在或不可读: " + publicKeyLocation);
|
||||||
|
}
|
||||||
|
String pem = readText(publicKeyResource);
|
||||||
|
String normalizedPem = pem
|
||||||
|
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||||
|
.replace("-----END PUBLIC KEY-----", "")
|
||||||
|
.replaceAll("\\s+", "");
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(normalizedPem);
|
||||||
|
return KeyFactory.getInstance(SIGNATURE_ALGORITHM).generatePublic(new X509EncodedKeySpec(keyBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readText(Resource resource) throws IOException {
|
||||||
|
try (InputStream inputStream = resource.getInputStream()) {
|
||||||
|
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolvedLicenseResource resolveLicenseResource(String location) {
|
||||||
|
String normalizedLocation = location.trim();
|
||||||
|
if (normalizedLocation.startsWith("file:")) {
|
||||||
|
return resolveFileLicenseResource(normalizedLocation, normalizedLocation.substring("file:".length()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource resource = resourceLoader.getResource(normalizedLocation);
|
||||||
|
if (!resource.exists() || !resource.isReadable()) {
|
||||||
|
throw EasyflowLicenseException.missing("lic 文件不存在", normalizedLocation);
|
||||||
|
}
|
||||||
|
return new ResolvedLicenseResource(resource, normalizedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolvedLicenseResource resolveFileLicenseResource(String originalLocation, String rawPath) {
|
||||||
|
Path candidatePath = Paths.get(rawPath).toAbsolutePath().normalize();
|
||||||
|
|
||||||
|
if (Files.isDirectory(candidatePath)) {
|
||||||
|
return resolveSingleLicFile(candidatePath, originalLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Files.exists(candidatePath) && Files.isRegularFile(candidatePath) && Files.isReadable(candidatePath)) {
|
||||||
|
return new ResolvedLicenseResource(resourceLoader.getResource(candidatePath.toUri().toString()), candidatePath.toUri().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidatePath.getFileName() != null
|
||||||
|
&& candidatePath.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".lic")) {
|
||||||
|
Path parent = candidatePath.getParent();
|
||||||
|
if (parent != null && Files.isDirectory(parent)) {
|
||||||
|
return resolveSingleLicFile(parent, originalLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw EasyflowLicenseException.missing("lic 文件不存在", originalLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolvedLicenseResource resolveSingleLicFile(Path directory, String originalLocation) {
|
||||||
|
try (Stream<Path> stream = Files.list(directory)) {
|
||||||
|
List<Path> licFiles = stream
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.filter(Files::isReadable)
|
||||||
|
.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".lic"))
|
||||||
|
.sorted()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (licFiles.isEmpty()) {
|
||||||
|
throw EasyflowLicenseException.missing("lic 文件不存在", originalLocation);
|
||||||
|
}
|
||||||
|
if (licFiles.size() > 1) {
|
||||||
|
throw EasyflowLicenseException.missing("检测到多个 .lic 文件,无法确定应使用哪个文件", originalLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path licFile = licFiles.get(0);
|
||||||
|
return new ResolvedLicenseResource(resourceLoader.getResource(licFile.toUri().toString()), licFile.toUri().toString());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw EasyflowLicenseException.missing("lic 文件读取失败", originalLocation, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(JsonNode node, String field, String location, String licenseId, String keyId) {
|
||||||
|
JsonNode value = node.path(field);
|
||||||
|
if (value.isMissingNode() || value.isNull()) {
|
||||||
|
throw EasyflowLicenseException.invalid(field + " 缺失", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
return normalizeRequired(value.asText(), field, location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode requireNode(JsonNode node, String field, String location, String licenseId, String keyId) {
|
||||||
|
JsonNode value = node.path(field);
|
||||||
|
if (value.isMissingNode() || value.isNull()) {
|
||||||
|
throw EasyflowLicenseException.invalid(field + " 缺失", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant parseInstant(String value, String field, String location, String licenseId, String keyId) {
|
||||||
|
try {
|
||||||
|
return Instant.parse(value);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw EasyflowLicenseException.invalid(field + " 时间格式非法", location, licenseId, keyId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeRequired(String value, String field, String location, String licenseId, String keyId) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (!StringUtils.hasText(normalized)) {
|
||||||
|
if (location != null || licenseId != null || keyId != null) {
|
||||||
|
throw EasyflowLicenseException.invalid(field + " 不能为空", location, licenseId, keyId);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException(field + " 不能为空");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已解析的 license 资源。
|
||||||
|
*
|
||||||
|
* @param resource Spring 资源
|
||||||
|
* @param actualLocation 实际使用的位置
|
||||||
|
*/
|
||||||
|
private record ResolvedLicenseResource(Resource resource, String actualLocation) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* License payload 的 canonical JSON 序列化器。
|
||||||
|
*/
|
||||||
|
public final class LicenseCanonicalJsonSerializer {
|
||||||
|
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private LicenseCanonicalJsonSerializer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 节点序列化为 canonical JSON 字符串。
|
||||||
|
*
|
||||||
|
* @param node JSON 节点
|
||||||
|
* @return canonical JSON
|
||||||
|
*/
|
||||||
|
public static String serialize(JsonNode node) {
|
||||||
|
try {
|
||||||
|
return OBJECT_MAPPER.writeValueAsString(sortNode(node));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("无法序列化 canonical JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归排序 JSON 节点。
|
||||||
|
*
|
||||||
|
* @param node JSON 节点
|
||||||
|
* @return 排序后的对象
|
||||||
|
*/
|
||||||
|
static Object sortNode(JsonNode node) {
|
||||||
|
if (node == null || node.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (node.isObject()) {
|
||||||
|
TreeMap<String, Object> sorted = new TreeMap<>();
|
||||||
|
ObjectNode objectNode = (ObjectNode) node;
|
||||||
|
Iterator<Map.Entry<String, JsonNode>> fields = objectNode.fields();
|
||||||
|
while (fields.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> entry = fields.next();
|
||||||
|
sorted.put(entry.getKey(), sortNode(entry.getValue()));
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
List<Object> items = new ArrayList<>();
|
||||||
|
ArrayNode arrayNode = (ArrayNode) node;
|
||||||
|
for (JsonNode item : arrayNode) {
|
||||||
|
items.add(sortNode(item));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
if (node.isTextual()) {
|
||||||
|
return node.textValue();
|
||||||
|
}
|
||||||
|
if (node.isBoolean()) {
|
||||||
|
return node.booleanValue();
|
||||||
|
}
|
||||||
|
if (node.isIntegralNumber()) {
|
||||||
|
return node.bigIntegerValue();
|
||||||
|
}
|
||||||
|
if (node.isFloatingPointNumber()) {
|
||||||
|
return node.decimalValue();
|
||||||
|
}
|
||||||
|
return node.asText();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前机器的授权指纹输入信息。
|
||||||
|
*
|
||||||
|
* @param machineId 操作系统级机器标识
|
||||||
|
* @param productUuid 硬件或固件级设备标识
|
||||||
|
* @param macAddresses 网卡 MAC 地址文本
|
||||||
|
*/
|
||||||
|
public record MachineIdentity(String machineId, String productUuid, String macAddresses) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集当前机器的授权指纹输入信息。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class MachineIdentityCollector {
|
||||||
|
|
||||||
|
private static final Duration COMMAND_TIMEOUT = Duration.ofSeconds(10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集当前机器的授权标识信息。
|
||||||
|
*
|
||||||
|
* @return 当前机器标识
|
||||||
|
*/
|
||||||
|
public MachineIdentity collect() {
|
||||||
|
String osName = currentOsName().toLowerCase(Locale.ROOT);
|
||||||
|
if (osName.contains("win")) {
|
||||||
|
return collectWindowsIdentity();
|
||||||
|
}
|
||||||
|
if (osName.contains("mac") || osName.contains("darwin")) {
|
||||||
|
return collectMacIdentity();
|
||||||
|
}
|
||||||
|
if (osName.contains("nux") || osName.contains("linux")) {
|
||||||
|
return collectLinuxIdentity();
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("不支持的操作系统: " + currentOsName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前操作系统名称。
|
||||||
|
*
|
||||||
|
* @return 操作系统名称
|
||||||
|
*/
|
||||||
|
protected String currentOsName() {
|
||||||
|
return System.getProperty("os.name", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集 Linux 机器信息。
|
||||||
|
*
|
||||||
|
* @return 机器信息
|
||||||
|
*/
|
||||||
|
protected MachineIdentity collectLinuxIdentity() {
|
||||||
|
String machineId = executeShellCommand("读取 Linux machineId", "cat /etc/machine-id");
|
||||||
|
String productUuid = executeShellCommand("读取 Linux productUuid", "cat /sys/class/dmi/id/product_uuid");
|
||||||
|
String macAddress = executeShellCommand("读取 Linux 默认网卡 MAC",
|
||||||
|
"cat /sys/class/net/$(ip route | awk '/default/ {print $5; exit}')/address");
|
||||||
|
return new MachineIdentity(machineId, productUuid, macAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集 macOS 机器信息。
|
||||||
|
*
|
||||||
|
* @return 机器信息
|
||||||
|
*/
|
||||||
|
protected MachineIdentity collectMacIdentity() {
|
||||||
|
String machineId = executeShellCommand("读取 macOS machineId",
|
||||||
|
"ioreg -rd1 -c IOPlatformExpertDevice | awk -F'\"' '/IOPlatformUUID/ {print $(NF-1)}'");
|
||||||
|
String productUuid = executeShellCommand("读取 macOS productUuid",
|
||||||
|
"ioreg -rd1 -c IOPlatformExpertDevice | awk -F'\"' '/IOPlatformUUID/ {print $(NF-1)}'");
|
||||||
|
String macAddress = executeShellCommand("读取 macOS 默认网卡 MAC",
|
||||||
|
"networksetup -listallhardwareports | awk '/Device/ {device=$2} /Ethernet Address/ {print $3; exit}'");
|
||||||
|
return new MachineIdentity(machineId, productUuid, macAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集 Windows 机器信息。
|
||||||
|
*
|
||||||
|
* @return 机器信息
|
||||||
|
*/
|
||||||
|
protected MachineIdentity collectWindowsIdentity() {
|
||||||
|
String machineId = executePowerShellCommand("读取 Windows machineId",
|
||||||
|
"(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid");
|
||||||
|
String productUuid = executePowerShellCommand("读取 Windows productUuid",
|
||||||
|
"(Get-CimInstance Win32_ComputerSystemProduct).UUID");
|
||||||
|
String macAddress = executePowerShellCommand("读取 Windows 默认网卡 MAC",
|
||||||
|
"(Get-NetAdapter | Where-Object {$_.Status -eq 'Up' -and $_.MacAddress} | Select-Object -First 1 -ExpandProperty MacAddress)");
|
||||||
|
return new MachineIdentity(machineId, productUuid, macAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 shell 执行命令。
|
||||||
|
*
|
||||||
|
* @param description 描述
|
||||||
|
* @param command 命令
|
||||||
|
* @return 命令输出
|
||||||
|
*/
|
||||||
|
protected String executeShellCommand(String description, String command) {
|
||||||
|
return executeCommand(description, List.of("sh", "-lc", command));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 PowerShell 执行命令。
|
||||||
|
*
|
||||||
|
* @param description 描述
|
||||||
|
* @param command 命令
|
||||||
|
* @return 命令输出
|
||||||
|
*/
|
||||||
|
protected String executePowerShellCommand(String description, String command) {
|
||||||
|
return executeCommand(description, List.of("powershell", "-NoProfile", "-Command", command));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行命令并返回文本输出。
|
||||||
|
*
|
||||||
|
* @param description 描述
|
||||||
|
* @param command 命令行
|
||||||
|
* @return 命令输出
|
||||||
|
*/
|
||||||
|
protected String executeCommand(String description, List<String> command) {
|
||||||
|
Process process = null;
|
||||||
|
try {
|
||||||
|
process = new ProcessBuilder(command)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start();
|
||||||
|
boolean finished = process.waitFor(COMMAND_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
throw new IllegalStateException(description + "超时");
|
||||||
|
}
|
||||||
|
|
||||||
|
String output = readStream(process.getInputStream()).trim();
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
throw new IllegalStateException(description + "失败,退出码=" + process.exitValue() + ",输出=" + output);
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(output)) {
|
||||||
|
throw new IllegalStateException(description + "失败,输出为空");
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(description + "失败,无法执行命令", e);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException(description + "失败,命令执行被中断", e);
|
||||||
|
} finally {
|
||||||
|
if (process != null) {
|
||||||
|
process.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取输入流内容。
|
||||||
|
*
|
||||||
|
* @param inputStream 输入流
|
||||||
|
* @return 文本内容
|
||||||
|
* @throws IOException 读取失败
|
||||||
|
*/
|
||||||
|
protected String readStream(InputStream inputStream) throws IOException {
|
||||||
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||||
|
inputStream.transferTo(outputStream);
|
||||||
|
return outputStream.toString(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.springframework.core.env.MapPropertySource;
|
||||||
|
import org.springframework.core.env.StandardEnvironment;
|
||||||
|
import org.springframework.core.io.DefaultResourceLoader;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EasyflowLicenseVerifier} 测试。
|
||||||
|
*/
|
||||||
|
public class EasyflowLicenseVerifierTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-05-10T12:00:00Z"), ZoneOffset.UTC);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合法 license 应校验通过。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldVerifyValidLicense() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
EasyflowLicenseVerificationResult result = verifier.verify(files.licenseLocation);
|
||||||
|
|
||||||
|
Assert.assertEquals("LIC-20260510-120000", result.licenseId());
|
||||||
|
Assert.assertEquals("prod-2026-01", result.keyId());
|
||||||
|
Assert.assertEquals("STANDARD", result.licenseType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当配置目录时,应自动找到唯一的 .lic 文件。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldResolveSingleLicFileFromDirectory() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
EasyflowLicenseVerificationResult result = verifier.verify("file:" + Path.of(files.licensePath).getParent());
|
||||||
|
|
||||||
|
Assert.assertEquals("LIC-20260510-120000", result.licenseId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当配置的固定文件不存在时,应回退到同目录唯一的 .lic 文件。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFallbackToSingleLicFileInSameDirectory() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
EasyflowLicenseVerificationResult result = verifier.verify("file:" + Path.of(files.licensePath).getParent().resolve("easyflow.lic"));
|
||||||
|
|
||||||
|
Assert.assertEquals("LIC-20260510-120000", result.licenseId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缺失 license 文件时应抛出缺失异常。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenLicenseFileMissing() {
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier("classpath:missing-public.pem",
|
||||||
|
new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
verifier.verify("file:/path/not-exists.lic");
|
||||||
|
Assert.fail("预期应抛出异常");
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
Assert.assertTrue(ex.isMissing());
|
||||||
|
Assert.assertTrue(ex.getMessage().contains("不存在"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同目录存在多个 .lic 文件时应报定位失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenMultipleLicFilesExist() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
|
||||||
|
Files.writeString(Path.of(files.licensePath).getParent().resolve("other.lic"), "dummy", StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
try {
|
||||||
|
verifier.verify("file:" + Path.of(files.licensePath).getParent());
|
||||||
|
Assert.fail("预期应抛出异常");
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
Assert.assertTrue(ex.isMissing());
|
||||||
|
Assert.assertTrue(ex.getMessage().contains("多个 .lic"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非法前缀应校验失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenPrefixInvalid() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, true);
|
||||||
|
Files.writeString(Path.of(files.licensePath), "BAD." + Files.readString(Path.of(files.licensePath)), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
try {
|
||||||
|
verifier.verify(files.licenseLocation);
|
||||||
|
Assert.fail("预期应抛出异常");
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
Assert.assertFalse(ex.isMissing());
|
||||||
|
Assert.assertTrue(ex.getMessage().contains("前缀"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非法 product 应校验失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenProductInvalid() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "OtherFlow"), identity, false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
try {
|
||||||
|
verifier.verify(files.licenseLocation);
|
||||||
|
Assert.fail("预期应抛出异常");
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
Assert.assertTrue(ex.getMessage().contains("product"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已过期的 STANDARD license 应校验失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenLicenseExpired() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-05-01T00:00:00Z", "EasyFlow"), identity, false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
try {
|
||||||
|
verifier.verify(files.licenseLocation);
|
||||||
|
Assert.fail("预期应抛出异常");
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
Assert.assertTrue(ex.getMessage().contains("过期"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEV license 可不包含 expiresAt。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldAllowDevLicenseWithoutExpiresAt() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "DEV", null, "EasyFlow"), identity, false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
EasyflowLicenseVerificationResult result = verifier.verify(files.licenseLocation);
|
||||||
|
Assert.assertNull(result.expiresAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机器指纹不匹配时应校验失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenFingerprintMismatch() throws Exception {
|
||||||
|
MachineIdentity licenseIdentity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
MachineIdentity runtimeIdentity = new MachineIdentity("machine-two", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(licenseIdentity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), licenseIdentity, false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, runtimeIdentity);
|
||||||
|
try {
|
||||||
|
verifier.verify(files.licenseLocation);
|
||||||
|
Assert.fail("预期应抛出异常");
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
Assert.assertTrue(ex.getMessage().contains("指纹不匹配"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* classpath 资源应可被正确读取并校验。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldVerifyClasspathLicense() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
|
||||||
|
writeClasspathTestResource("test-license/public.pem", Files.readString(Path.of(files.publicKeyPath), StandardCharsets.UTF_8));
|
||||||
|
writeClasspathTestResource("test-license/license.lic", Files.readString(Path.of(files.licensePath), StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier("classpath:test-license/public.pem", identity);
|
||||||
|
EasyflowLicenseVerificationResult result = verifier.verify("classpath:test-license/license.lic");
|
||||||
|
Assert.assertEquals("LIC-20260510-120000", result.licenseId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formatVersion 非 1 时应校验失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenFormatVersionInvalid() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
Map<String, Object> payload = createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(payload, identity, false, 2, "Ed25519", payload.get("fingerprintAlgorithm").toString(), false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
assertInvalid(verifier, files.licenseLocation, "formatVersion");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* alg 非 Ed25519 时应校验失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenAlgorithmInvalid() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
Map<String, Object> payload = createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(payload, identity, false, 1, "RSA", payload.get("fingerprintAlgorithm").toString(), false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
assertInvalid(verifier, files.licenseLocation, "签名算法");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fingerprintAlgorithm 不匹配时应校验失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenFingerprintAlgorithmInvalid() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
Map<String, Object> payload = createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(payload, identity, false, 1, "Ed25519", "sha256(other)", false);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
assertInvalid(verifier, files.licenseLocation, "fingerprintAlgorithm");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 篡改签名后应校验失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenSignatureTampered() throws Exception {
|
||||||
|
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
|
||||||
|
Map<String, Object> payload = createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow");
|
||||||
|
TestLicenseFiles files = createLicenseFiles(payload, identity, false, 1, "Ed25519", payload.get("fingerprintAlgorithm").toString(), true);
|
||||||
|
|
||||||
|
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
|
||||||
|
assertInvalid(verifier, files.licenseLocation, "签名校验失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MAC 地址应完成归一化、去重与排序。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldNormalizeMacAddresses() {
|
||||||
|
Assert.assertEquals("aa:bb:cc:dd:ee:ff",
|
||||||
|
EasyflowLicenseVerifier.normalizeMacAddress("AA-BB-CC-DD-EE-FF"));
|
||||||
|
Assert.assertEquals(
|
||||||
|
java.util.List.of("11:22:33:44:55:66", "aa:bb:cc:dd:ee:ff"),
|
||||||
|
EasyflowLicenseVerifier.normalizeMacAddresses("AA-BB-CC-DD-EE-FF\n11:22:33:44:55:66\naa:bb:cc:dd:ee:ff"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动前校验器应在缺失 license 时直接失败。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailBootstrapValidatorWhenLicenseMissing() {
|
||||||
|
EasyflowLicenseBootstrapValidator validator = new EasyflowLicenseBootstrapValidator(
|
||||||
|
createVerifier("classpath:missing-public.pem",
|
||||||
|
new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF"))
|
||||||
|
);
|
||||||
|
StandardEnvironment environment = new StandardEnvironment();
|
||||||
|
environment.getPropertySources().addFirst(
|
||||||
|
new MapPropertySource("test", Map.of("easyflow.license.location", "file:/path/not-exists.lic"))
|
||||||
|
);
|
||||||
|
validator.setEnvironment(environment);
|
||||||
|
|
||||||
|
try {
|
||||||
|
validator.postProcessBeanFactory(null);
|
||||||
|
Assert.fail("预期应抛出异常");
|
||||||
|
} catch (IllegalStateException ex) {
|
||||||
|
Assert.assertTrue(ex.getMessage().contains("不存在") || ex.getMessage().contains("未配置"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private EasyflowLicenseVerifier createVerifier(String publicKeyLocation, MachineIdentity identity) {
|
||||||
|
return new EasyflowLicenseVerifier(
|
||||||
|
new DefaultResourceLoader(),
|
||||||
|
new StubMachineIdentityCollector(identity),
|
||||||
|
FIXED_CLOCK,
|
||||||
|
publicKeyLocation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TestLicenseFiles createLicenseFiles(Map<String, Object> payload,
|
||||||
|
MachineIdentity identity,
|
||||||
|
boolean keepOriginalPrefix) throws Exception {
|
||||||
|
return createLicenseFiles(payload, identity, keepOriginalPrefix, 1, "Ed25519",
|
||||||
|
payload.get("fingerprintAlgorithm").toString(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TestLicenseFiles createLicenseFiles(Map<String, Object> payload,
|
||||||
|
MachineIdentity identity,
|
||||||
|
boolean keepOriginalPrefix,
|
||||||
|
int formatVersion,
|
||||||
|
String algorithm,
|
||||||
|
String fingerprintAlgorithm,
|
||||||
|
boolean tamperSignature) throws Exception {
|
||||||
|
KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
payload.put("fingerprintAlgorithm", fingerprintAlgorithm);
|
||||||
|
String canonicalPayload = buildCanonicalPayloadJson(payload);
|
||||||
|
String signature = sign(canonicalPayload, keyPair);
|
||||||
|
if (tamperSignature) {
|
||||||
|
signature = Base64.getEncoder().encodeToString("tampered-signature".getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> envelope = new LinkedHashMap<>();
|
||||||
|
envelope.put("formatVersion", formatVersion);
|
||||||
|
envelope.put("alg", algorithm);
|
||||||
|
envelope.put("keyId", "prod-2026-01");
|
||||||
|
envelope.put("payload", payload);
|
||||||
|
envelope.put("signature", signature);
|
||||||
|
|
||||||
|
String licenseContent = EasyflowLicenseVerifier.LICENSE_FILE_PREFIX
|
||||||
|
+ Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString(OBJECT_MAPPER.writeValueAsBytes(envelope));
|
||||||
|
if (!keepOriginalPrefix) {
|
||||||
|
licenseContent = licenseContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
Path tempDir = Files.createTempDirectory("easyflow-license-test");
|
||||||
|
Path publicKeyPath = tempDir.resolve("public.pem");
|
||||||
|
Path licensePath = tempDir.resolve("license.lic");
|
||||||
|
Files.writeString(publicKeyPath, toPublicPem(keyPair), StandardCharsets.UTF_8);
|
||||||
|
Files.writeString(licensePath, licenseContent, StandardCharsets.UTF_8);
|
||||||
|
return new TestLicenseFiles(publicKeyPath.toUri().toString(),
|
||||||
|
licensePath.toUri().toString(),
|
||||||
|
publicKeyPath.toString(),
|
||||||
|
licensePath.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> createPayload(MachineIdentity identity,
|
||||||
|
String licenseType,
|
||||||
|
String expiresAt,
|
||||||
|
String product) {
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("licenseId", "LIC-20260510-120000");
|
||||||
|
payload.put("product", product);
|
||||||
|
payload.put("customerName", "Test Customer");
|
||||||
|
payload.put("environment", "PROD");
|
||||||
|
payload.put("licenseType", licenseType);
|
||||||
|
payload.put("issuedAt", "2026-05-10T12:00:00Z");
|
||||||
|
payload.put("expiresAt", expiresAt);
|
||||||
|
payload.put("fingerprintAlgorithm", EasyflowLicenseVerifier.FINGERPRINT_ALGORITHM);
|
||||||
|
payload.put("machineFingerprint", EasyflowLicenseVerifier.computeMachineFingerprint(identity));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildCanonicalPayloadJson(Map<String, Object> payload) {
|
||||||
|
return "{"
|
||||||
|
+ "\"customerName\":\"" + payload.get("customerName") + "\","
|
||||||
|
+ "\"environment\":\"" + payload.get("environment") + "\","
|
||||||
|
+ "\"expiresAt\":" + quoteOrNull(payload.get("expiresAt")) + ","
|
||||||
|
+ "\"fingerprintAlgorithm\":\"" + payload.get("fingerprintAlgorithm") + "\","
|
||||||
|
+ "\"issuedAt\":\"" + payload.get("issuedAt") + "\","
|
||||||
|
+ "\"licenseId\":\"" + payload.get("licenseId") + "\","
|
||||||
|
+ "\"licenseType\":\"" + payload.get("licenseType") + "\","
|
||||||
|
+ "\"machineFingerprint\":\"" + payload.get("machineFingerprint") + "\","
|
||||||
|
+ "\"product\":\"" + payload.get("product") + "\""
|
||||||
|
+ "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quoteOrNull(Object value) {
|
||||||
|
return value == null ? "null" : "\"" + value + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sign(String canonicalPayload, KeyPair keyPair) throws Exception {
|
||||||
|
Signature signer = Signature.getInstance("Ed25519");
|
||||||
|
signer.initSign(keyPair.getPrivate());
|
||||||
|
signer.update(canonicalPayload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getEncoder().encodeToString(signer.sign());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toPublicPem(KeyPair keyPair) {
|
||||||
|
String base64 = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.UTF_8))
|
||||||
|
.encodeToString(keyPair.getPublic().getEncoded());
|
||||||
|
return "-----BEGIN PUBLIC KEY-----\n" + base64 + "\n-----END PUBLIC KEY-----\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertInvalid(EasyflowLicenseVerifier verifier, String location, String expectedMessagePart) {
|
||||||
|
try {
|
||||||
|
verifier.verify(location);
|
||||||
|
Assert.fail("预期应抛出异常");
|
||||||
|
} catch (EasyflowLicenseException ex) {
|
||||||
|
Assert.assertTrue(ex.getMessage().contains(expectedMessagePart));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeClasspathTestResource(String relativePath, String content) throws Exception {
|
||||||
|
Path path = Path.of("target/test-classes").resolve(relativePath);
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用固定机器信息采集器。
|
||||||
|
*/
|
||||||
|
private static class StubMachineIdentityCollector extends MachineIdentityCollector {
|
||||||
|
|
||||||
|
private final MachineIdentity identity;
|
||||||
|
|
||||||
|
private StubMachineIdentityCollector(MachineIdentity identity) {
|
||||||
|
this.identity = identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接返回固定机器信息。
|
||||||
|
*
|
||||||
|
* @return 固定机器信息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public MachineIdentity collect() {
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试临时文件信息。
|
||||||
|
*
|
||||||
|
* @param publicKeyLocation 公钥位置
|
||||||
|
* @param licenseLocation license 位置
|
||||||
|
* @param licensePath license 文件路径
|
||||||
|
*/
|
||||||
|
private record TestLicenseFiles(String publicKeyLocation,
|
||||||
|
String licenseLocation,
|
||||||
|
String publicKeyPath,
|
||||||
|
String licensePath) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link LicenseCanonicalJsonSerializer} 测试。
|
||||||
|
*/
|
||||||
|
public class LicenseCanonicalJsonSerializerTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应按 key 递归排序对象,且保持数组顺序。
|
||||||
|
*
|
||||||
|
* @throws Exception 解析失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldSerializeCanonicalJson() throws Exception {
|
||||||
|
JsonNode jsonNode = OBJECT_MAPPER.readTree("{\"b\":1,\"arr\":[{\"b\":2,\"a\":1},3],\"a\":{\"d\":4,\"c\":3}}");
|
||||||
|
String canonicalJson = LicenseCanonicalJsonSerializer.serialize(jsonNode);
|
||||||
|
Assert.assertEquals("{\"a\":{\"c\":3,\"d\":4},\"arr\":[{\"a\":1,\"b\":2},3],\"b\":1}", canonicalJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package tech.easyflow.autoconfig.license;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link MachineIdentityCollector} 测试。
|
||||||
|
*/
|
||||||
|
public class MachineIdentityCollectorTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux 采集命令应按约定返回三项机器参数。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldCollectLinuxIdentity() {
|
||||||
|
TestMachineIdentityCollector collector = new TestMachineIdentityCollector("Linux");
|
||||||
|
collector.shellOutputs.put("cat /etc/machine-id", "linux-machine");
|
||||||
|
collector.shellOutputs.put("cat /sys/class/dmi/id/product_uuid", "linux-product");
|
||||||
|
collector.shellOutputs.put("cat /sys/class/net/$(ip route | awk '/default/ {print $5; exit}')/address", "aa:bb:cc:dd:ee:ff");
|
||||||
|
|
||||||
|
MachineIdentity identity = collector.collect();
|
||||||
|
Assert.assertEquals("linux-machine", identity.machineId());
|
||||||
|
Assert.assertEquals("linux-product", identity.productUuid());
|
||||||
|
Assert.assertEquals("aa:bb:cc:dd:ee:ff", identity.macAddresses());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS 采集命令应按约定返回三项机器参数。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldCollectMacIdentity() {
|
||||||
|
TestMachineIdentityCollector collector = new TestMachineIdentityCollector("Mac OS X");
|
||||||
|
collector.shellOutputs.put("ioreg -rd1 -c IOPlatformExpertDevice | awk -F'\"' '/IOPlatformUUID/ {print $(NF-1)}'", "mac-platform-uuid");
|
||||||
|
collector.shellOutputs.put("networksetup -listallhardwareports | awk '/Device/ {device=$2} /Ethernet Address/ {print $3; exit}'", "AA-BB-CC-DD-EE-FF");
|
||||||
|
|
||||||
|
MachineIdentity identity = collector.collect();
|
||||||
|
Assert.assertEquals("mac-platform-uuid", identity.machineId());
|
||||||
|
Assert.assertEquals("mac-platform-uuid", identity.productUuid());
|
||||||
|
Assert.assertEquals("AA-BB-CC-DD-EE-FF", identity.macAddresses());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows 采集命令应按约定返回三项机器参数。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldCollectWindowsIdentity() {
|
||||||
|
TestMachineIdentityCollector collector = new TestMachineIdentityCollector("Windows 11");
|
||||||
|
collector.powerShellOutputs.put("(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid", "windows-machine");
|
||||||
|
collector.powerShellOutputs.put("(Get-CimInstance Win32_ComputerSystemProduct).UUID", "windows-product");
|
||||||
|
collector.powerShellOutputs.put("(Get-NetAdapter | Where-Object {$_.Status -eq 'Up' -and $_.MacAddress} | Select-Object -First 1 -ExpandProperty MacAddress)", "AA-BB-CC-DD-EE-FF");
|
||||||
|
|
||||||
|
MachineIdentity identity = collector.collect();
|
||||||
|
Assert.assertEquals("windows-machine", identity.machineId());
|
||||||
|
Assert.assertEquals("windows-product", identity.productUuid());
|
||||||
|
Assert.assertEquals("AA-BB-CC-DD-EE-FF", identity.macAddresses());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空输出应被识别为采集失败。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenCommandOutputIsBlank() {
|
||||||
|
TestMachineIdentityCollector collector = new TestMachineIdentityCollector("Linux");
|
||||||
|
collector.shellOutputs.put("cat /etc/machine-id", "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
collector.collect();
|
||||||
|
Assert.fail("预期应抛出异常");
|
||||||
|
} catch (IllegalStateException ex) {
|
||||||
|
Assert.assertTrue(ex.getMessage().contains("输出为空"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用机器信息采集器。
|
||||||
|
*/
|
||||||
|
private static class TestMachineIdentityCollector extends MachineIdentityCollector {
|
||||||
|
|
||||||
|
private final String osName;
|
||||||
|
private final Map<String, String> shellOutputs = new HashMap<>();
|
||||||
|
private final Map<String, String> powerShellOutputs = new HashMap<>();
|
||||||
|
|
||||||
|
private TestMachineIdentityCollector(String osName) {
|
||||||
|
this.osName = osName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回固定的操作系统名称。
|
||||||
|
*
|
||||||
|
* @return 操作系统名称
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected String currentOsName() {
|
||||||
|
return osName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回预设 shell 输出。
|
||||||
|
*
|
||||||
|
* @param description 描述
|
||||||
|
* @param command 命令
|
||||||
|
* @return 命令输出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected String executeShellCommand(String description, String command) {
|
||||||
|
return lookupOutput(description, command, shellOutputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回预设 PowerShell 输出。
|
||||||
|
*
|
||||||
|
* @param description 描述
|
||||||
|
* @param command 命令
|
||||||
|
* @return 命令输出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected String executePowerShellCommand(String description, String command) {
|
||||||
|
return lookupOutput(description, command, powerShellOutputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String lookupOutput(String description, String command, Map<String, String> outputs) {
|
||||||
|
if (!outputs.containsKey(command)) {
|
||||||
|
throw new IllegalStateException(description + "失败,命令未配置输出");
|
||||||
|
}
|
||||||
|
String output = outputs.get(command);
|
||||||
|
if (output == null || output.trim().isEmpty()) {
|
||||||
|
throw new IllegalStateException(description + "失败,输出为空");
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -487,15 +487,17 @@ public class ChatAnalyticalDBRepository {
|
|||||||
* @param startDate 开始日期,包含
|
* @param startDate 开始日期,包含
|
||||||
* @param endDate 结束日期,不包含
|
* @param endDate 结束日期,不包含
|
||||||
* @param tenantId 租户 ID,空表示全局
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param assistantId 智能体 ID,空表示全部
|
||||||
* @param limit 返回条数
|
* @param limit 返回条数
|
||||||
* @return 排行列表
|
* @return 排行列表
|
||||||
*/
|
*/
|
||||||
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
|
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
|
||||||
LocalDate endDate,
|
LocalDate endDate,
|
||||||
BigInteger tenantId,
|
BigInteger tenantId,
|
||||||
int limit) {
|
BigInteger assistantId,
|
||||||
|
Integer limit) {
|
||||||
assertAvailable();
|
assertAvailable();
|
||||||
int safeLimit = Math.max(limit, 1);
|
Integer safeLimit = limit == null ? null : Math.max(limit, 1);
|
||||||
List<Object> args = new java.util.ArrayList<>();
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
sql.append("SELECT agg.user_id AS user_id, ")
|
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(?)");
|
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
|
||||||
args.add(startDate.toString());
|
args.add(startDate.toString());
|
||||||
args.add(endDate.toString());
|
args.add(endDate.toString());
|
||||||
|
if (assistantId != null) {
|
||||||
|
sql.append(" AND agg.assistant_id = ?");
|
||||||
|
args.add(assistantId);
|
||||||
|
}
|
||||||
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
sql.append(" GROUP BY agg.user_id")
|
sql.append(" GROUP BY agg.user_id")
|
||||||
.append(") agg ")
|
.append(") agg ")
|
||||||
@@ -520,9 +526,11 @@ public class ChatAnalyticalDBRepository {
|
|||||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||||
sql.append(" GROUP BY user_id")
|
sql.append(" GROUP BY user_id")
|
||||||
.append(") snapshot ON snapshot.user_id = agg.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("ORDER BY agg.session_total DESC, agg.message_total DESC, agg.user_id ASC ");
|
||||||
.append("LIMIT ?");
|
if (safeLimit != null) {
|
||||||
|
sql.append("LIMIT ?");
|
||||||
args.add(safeLimit);
|
args.add(safeLimit);
|
||||||
|
}
|
||||||
|
|
||||||
return analyticalDBOperations.query(
|
return analyticalDBOperations.query(
|
||||||
sql.toString(),
|
sql.toString(),
|
||||||
|
|||||||
@@ -94,13 +94,15 @@ public interface ChatDashboardQueryService {
|
|||||||
* @param startDate 开始日期,包含当天
|
* @param startDate 开始日期,包含当天
|
||||||
* @param endDate 结束日期,不包含当天
|
* @param endDate 结束日期,不包含当天
|
||||||
* @param tenantId 租户 ID,空表示全局
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param assistantId 智能体 ID,空表示全部
|
||||||
* @param limit 返回条数
|
* @param limit 返回条数
|
||||||
* @return 活跃用户排行
|
* @return 活跃用户排行
|
||||||
*/
|
*/
|
||||||
List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
|
List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
|
||||||
LocalDate endDate,
|
LocalDate endDate,
|
||||||
BigInteger tenantId,
|
BigInteger tenantId,
|
||||||
int limit);
|
BigInteger assistantId,
|
||||||
|
Integer limit);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前分析库是否可用。
|
* 当前分析库是否可用。
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService
|
|||||||
* @param startDate 开始日期,包含当天
|
* @param startDate 开始日期,包含当天
|
||||||
* @param endDate 结束日期,不包含当天
|
* @param endDate 结束日期,不包含当天
|
||||||
* @param tenantId 租户 ID,空表示全局
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param assistantId 智能体 ID,空表示全部
|
||||||
* @param limit 返回条数
|
* @param limit 返回条数
|
||||||
* @return 活跃用户排行
|
* @return 活跃用户排行
|
||||||
*/
|
*/
|
||||||
@@ -148,11 +149,12 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService
|
|||||||
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
|
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
|
||||||
LocalDate endDate,
|
LocalDate endDate,
|
||||||
BigInteger tenantId,
|
BigInteger tenantId,
|
||||||
int limit) {
|
BigInteger assistantId,
|
||||||
|
Integer limit) {
|
||||||
if (!available()) {
|
if (!available()) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, limit);
|
return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, assistantId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -104,6 +104,28 @@ public class ChatAnalyticalDBRepositoryTest {
|
|||||||
Assert.assertTrue(operations.lastQuerySql.contains("(agg.assistant_id IN (?) OR agg.assistant_id IS NULL)"));
|
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 ?"));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造仓储实例。
|
* 构造仓储实例。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -88,6 +88,16 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<version>3.4.2</version>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>**/*.lic</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ spring:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
easyflow:
|
easyflow:
|
||||||
|
license:
|
||||||
|
location: classpath:easyflow.lic
|
||||||
chat:
|
chat:
|
||||||
# SSE 超时时间(毫秒),默认 10 分钟,可按需调整
|
# SSE 超时时间(毫秒),默认 10 分钟,可按需调整
|
||||||
sse-timeout-ms: 600000
|
sse-timeout-ms: 600000
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAg4rl3LjLI1Hc4CGsJCrZcieEp9gdWwHAUyDNoFjjts8=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { requestClient } from '#/api/request';
|
import { api, requestClient } from '#/api/request';
|
||||||
|
|
||||||
export type DashboardRange = '7d' | '30d' | 'custom' | 'today';
|
export type DashboardRange = '7d' | '30d' | 'custom' | 'today';
|
||||||
|
|
||||||
@@ -8,6 +8,10 @@ export interface DashboardOverviewQuery {
|
|||||||
startDate?: string;
|
startDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardUserRankQuery extends DashboardOverviewQuery {
|
||||||
|
assistantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
activeUserTotal: number;
|
activeUserTotal: number;
|
||||||
activeAssistantTotal: number;
|
activeAssistantTotal: number;
|
||||||
@@ -63,9 +67,10 @@ export interface DashboardDistributionItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardUserRankItem {
|
export interface DashboardUserRankItem {
|
||||||
assistantTotal: number;
|
|
||||||
label: string;
|
label: string;
|
||||||
|
loginName?: string;
|
||||||
messageTotal: number;
|
messageTotal: number;
|
||||||
|
nickname?: string;
|
||||||
sessionTotal: number;
|
sessionTotal: number;
|
||||||
userId?: number | string;
|
userId?: number | string;
|
||||||
}
|
}
|
||||||
@@ -78,7 +83,6 @@ export interface DashboardOverviewResponse {
|
|||||||
summary: DashboardSummary;
|
summary: DashboardSummary;
|
||||||
trends: DashboardTrendItem[];
|
trends: DashboardTrendItem[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
userRanks: DashboardUserRankItem[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardOverview(params: DashboardOverviewQuery) {
|
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<DashboardUserRankItem[]>(
|
||||||
|
'/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<string, any>) {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
DashboardTrendItem,
|
DashboardTrendItem,
|
||||||
DashboardUserRankItem,
|
DashboardUserRankItem,
|
||||||
|
DashboardUserRankQuery,
|
||||||
} from '#/api/dashboard';
|
} from '#/api/dashboard';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -25,19 +26,26 @@ import {
|
|||||||
import { AnalysisChartCard } from '@easyflow/common-ui';
|
import { AnalysisChartCard } from '@easyflow/common-ui';
|
||||||
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
|
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
|
||||||
import { useUserStore } from '@easyflow/stores';
|
import { useUserStore } from '@easyflow/stores';
|
||||||
import { convertToRgb } from '@easyflow/utils';
|
import { convertToRgb, downloadFileFromBlob } from '@easyflow/utils';
|
||||||
|
|
||||||
import { RefreshRight } from '@element-plus/icons-vue';
|
import { RefreshRight } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElButton,
|
ElButton,
|
||||||
ElDatePicker,
|
ElDatePicker,
|
||||||
ElEmpty,
|
ElEmpty,
|
||||||
|
ElOption,
|
||||||
ElPopover,
|
ElPopover,
|
||||||
ElRadioButton,
|
ElRadioButton,
|
||||||
ElRadioGroup,
|
ElRadioGroup,
|
||||||
|
ElSelect,
|
||||||
} from 'element-plus';
|
} 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';
|
type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive';
|
||||||
|
|
||||||
@@ -54,7 +62,13 @@ interface SummaryCardItem {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AssistantOptionItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
let greetingTimer: null | ReturnType<typeof setInterval> = null;
|
let greetingTimer: null | ReturnType<typeof setInterval> = null;
|
||||||
|
let userRankRequestId = 0;
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const now = ref(new Date());
|
const now = ref(new Date());
|
||||||
|
|
||||||
@@ -68,6 +82,15 @@ const isLoading = ref(false);
|
|||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const trendMode = ref<DashboardTrendMode>('usage');
|
const trendMode = ref<DashboardTrendMode>('usage');
|
||||||
const selectedAssistantTrendKeys = ref<string[]>([]);
|
const selectedAssistantTrendKeys = ref<string[]>([]);
|
||||||
|
const assistantOptions = ref<AssistantOptionItem[]>([
|
||||||
|
{ label: '全部智能体', value: '' },
|
||||||
|
]);
|
||||||
|
const assistantOptionsLoading = ref(false);
|
||||||
|
const selectedAssistantId = ref('');
|
||||||
|
const userRankItems = ref<DashboardUserRankItem[]>([]);
|
||||||
|
const userRankLoading = ref(false);
|
||||||
|
const userRankExportLoading = ref(false);
|
||||||
|
const userRankErrorMessage = ref('');
|
||||||
|
|
||||||
const trendChartRef = ref<EchartsUIType>();
|
const trendChartRef = ref<EchartsUIType>();
|
||||||
const {
|
const {
|
||||||
@@ -110,9 +133,6 @@ const assistantTrends = computed<DashboardAssistantTrendSeries[]>(
|
|||||||
const distribution = computed<DashboardDistributionItem[]>(
|
const distribution = computed<DashboardDistributionItem[]>(
|
||||||
() => overview.value?.distribution ?? [],
|
() => overview.value?.distribution ?? [],
|
||||||
);
|
);
|
||||||
const userRanks = computed<DashboardUserRankItem[]>(
|
|
||||||
() => overview.value?.userRanks ?? [],
|
|
||||||
);
|
|
||||||
const chatAvailable = computed(
|
const chatAvailable = computed(
|
||||||
() => overview.value?.chatStatus?.available !== false,
|
() => overview.value?.chatStatus?.available !== false,
|
||||||
);
|
);
|
||||||
@@ -278,22 +298,30 @@ const showAssistantTrendEmptySelection = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showTrendChart = computed(() => chatAvailable.value);
|
const showTrendChart = computed(() => chatAvailable.value);
|
||||||
|
const showUserRankLoading = computed(
|
||||||
|
() => userRankLoading.value && userRankItems.value.length === 0,
|
||||||
|
);
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getDashboardOverview({
|
const [data] = await Promise.all([
|
||||||
endDate: filters.range === 'custom' ? filters.endDate : undefined,
|
getDashboardOverview(buildOverviewQuery()),
|
||||||
range: filters.range,
|
loadAssistantOptions().catch(() => undefined),
|
||||||
startDate: filters.range === 'custom' ? filters.startDate : undefined,
|
]);
|
||||||
});
|
|
||||||
overview.value = data;
|
overview.value = data;
|
||||||
resetAssistantTrendSelection();
|
resetAssistantTrendSelection();
|
||||||
await renderCharts();
|
await renderCharts();
|
||||||
|
if (data.chatStatus?.available === false) {
|
||||||
|
resetUserRanks();
|
||||||
|
} else {
|
||||||
|
await loadUserRanks();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
overview.value = null;
|
overview.value = null;
|
||||||
|
resetUserRanks();
|
||||||
errorMessage.value =
|
errorMessage.value =
|
||||||
(error as Error)?.message || '工作台数据加载失败,请稍后重试。';
|
(error as Error)?.message || '工作台数据加载失败,请稍后重试。';
|
||||||
} finally {
|
} 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() {
|
async function renderCharts() {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (!showTrendChart.value) {
|
if (!showTrendChart.value) {
|
||||||
@@ -1057,10 +1189,58 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<AnalysisChartCard title="用户活跃榜">
|
<AnalysisChartCard title="用户活跃榜">
|
||||||
|
<template #extra v-if="chatAvailable">
|
||||||
|
<div class="flex w-full items-center justify-end gap-2 sm:w-auto">
|
||||||
|
<ElSelect
|
||||||
|
:model-value="selectedAssistantId"
|
||||||
|
class="w-[220px]"
|
||||||
|
:clearable="false"
|
||||||
|
filterable
|
||||||
|
placeholder="筛选智能体"
|
||||||
|
:loading="assistantOptionsLoading"
|
||||||
|
@update:model-value="handleUserRankAssistantChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in assistantOptions"
|
||||||
|
:key="item.value || item.label"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<ElButton
|
||||||
|
:loading="userRankExportLoading"
|
||||||
|
:disabled="userRankLoading"
|
||||||
|
@click="handleUserRankExport"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-if="chatAvailable">
|
<template v-if="chatAvailable">
|
||||||
<div v-if="userRanks.length > 0" class="space-y-3">
|
<div v-if="showUserRankLoading" class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in userRanks"
|
v-for="item in 2"
|
||||||
|
:key="item"
|
||||||
|
class="border-border/60 bg-muted/25 h-[104px] animate-pulse rounded-2xl border"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="userRankErrorMessage" class="flex min-h-[220px] items-center justify-center">
|
||||||
|
<ElEmpty description="用户活跃榜加载失败">
|
||||||
|
<template #default>
|
||||||
|
<div class="space-y-3 text-center">
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ userRankErrorMessage }}
|
||||||
|
</p>
|
||||||
|
<ElButton type="primary" @click="loadUserRanks">
|
||||||
|
重新加载
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElEmpty>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="userRankItems.length > 0" class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in userRankItems"
|
||||||
:key="item.userId || item.label"
|
:key="item.userId || item.label"
|
||||||
class="border-border/60 bg-card flex flex-col gap-4 rounded-2xl border px-4 py-4 md:flex-row md:items-center md:justify-between"
|
class="border-border/60 bg-card flex flex-col gap-4 rounded-2xl border px-4 py-4 md:flex-row md:items-center md:justify-between"
|
||||||
>
|
>
|
||||||
@@ -1074,28 +1254,17 @@ onBeforeUnmount(() => {
|
|||||||
<div class="truncate text-sm font-semibold">
|
<div class="truncate text-sm font-semibold">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground mt-1 text-xs">
|
|
||||||
消息 {{ formatCount(item.messageTotal) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
|
|
||||||
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
|
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
|
||||||
<div class="text-foreground text-base font-semibold">
|
<div class="text-foreground text-base font-semibold">
|
||||||
{{ formatCount(item.sessionTotal) }}
|
{{ formatCount(item.sessionTotal) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground mt-1 text-xs">会话数</div>
|
<div class="text-muted-foreground mt-1 text-xs">会话数</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
|
|
||||||
<div class="text-foreground text-base font-semibold">
|
|
||||||
{{ formatCount(item.assistantTotal) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground mt-1 text-xs">
|
|
||||||
使用智能体数
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="bg-muted/30 col-span-2 rounded-2xl px-4 py-3 text-right md:col-span-1"
|
class="bg-muted/30 rounded-2xl px-4 py-3 text-right"
|
||||||
>
|
>
|
||||||
<div class="text-foreground text-base font-semibold">
|
<div class="text-foreground text-base font-semibold">
|
||||||
{{ formatCount(item.messageTotal) }}
|
{{ formatCount(item.messageTotal) }}
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ withDefaults(defineProps<Props>(), {});
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<CardTitle class="text-xl">{{ title }}</CardTitle>
|
<CardTitle class="text-xl">{{ title }}</CardTitle>
|
||||||
|
<div
|
||||||
|
v-if="$slots.extra"
|
||||||
|
class="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto"
|
||||||
|
>
|
||||||
|
<slot name="extra"></slot>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
|||||||
Reference in New Issue
Block a user