parent
0170907f6d
commit
910fe72a1b
24 changed files with 2218 additions and 1 deletions
@ -0,0 +1,67 @@ |
||||
package org.springblade.lims.entry; |
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName; |
||||
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
import io.swagger.annotations.ApiModel; |
||||
import io.swagger.annotations.ApiModelProperty; |
||||
import lombok.Data; |
||||
import lombok.EqualsAndHashCode; |
||||
import org.springblade.core.mp.base.BaseEntity; |
||||
|
||||
import java.io.Serializable; |
||||
|
||||
/** |
||||
* 自定义指标配置 |
||||
* |
||||
* @author swj |
||||
*/ |
||||
@Data |
||||
@EqualsAndHashCode(callSuper = true) |
||||
@TableName("t_custom_metric_config") |
||||
@ApiModel(value = "CustomMetricConfig", description = "自定义指标配置") |
||||
public class CustomMetricConfig extends BaseEntity implements Serializable { |
||||
|
||||
private static final long serialVersionUID = 1L; |
||||
|
||||
/** |
||||
* 配置名称 |
||||
*/ |
||||
@ApiModelProperty("配置名称") |
||||
@JsonProperty("configName") |
||||
private String configName; |
||||
|
||||
/** |
||||
* 统计对象(user/dept) |
||||
*/ |
||||
@ApiModelProperty("统计对象(user/dept)") |
||||
@JsonProperty("statObject") |
||||
private String statObject; |
||||
|
||||
/** |
||||
* 统计字段(detectCount/taskCount/delayCount/reagentUsage) |
||||
*/ |
||||
@ApiModelProperty("统计字段(detectCount/taskCount/delayCount/reagentUsage)") |
||||
@JsonProperty("statField") |
||||
private String statField; |
||||
|
||||
/** |
||||
* 统计周期(day/week/month/year) |
||||
*/ |
||||
@ApiModelProperty("统计周期(day/week/month/year)") |
||||
@JsonProperty("period") |
||||
private String period; |
||||
|
||||
/** |
||||
* 聚合方式(sum/avg) |
||||
*/ |
||||
@ApiModelProperty("聚合方式(sum/avg)") |
||||
@JsonProperty("aggregation") |
||||
private String aggregation; |
||||
|
||||
/** |
||||
* 启用状态(0禁用 1启用) |
||||
*/ |
||||
@ApiModelProperty("启用状态(0禁用 1启用)") |
||||
@JsonProperty("enabled") |
||||
private Integer enabled; |
||||
} |
||||
@ -0,0 +1,62 @@ |
||||
package org.springblade.lims.entry; |
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName; |
||||
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
import io.swagger.annotations.ApiModel; |
||||
import io.swagger.annotations.ApiModelProperty; |
||||
import lombok.Data; |
||||
import lombok.EqualsAndHashCode; |
||||
import org.springblade.core.mp.base.BaseEntity; |
||||
|
||||
import java.io.Serializable; |
||||
import java.math.BigDecimal; |
||||
import java.util.Date; |
||||
|
||||
/** |
||||
* 自定义指标统计数据 |
||||
* |
||||
* @author swj |
||||
*/ |
||||
@Data |
||||
@EqualsAndHashCode(callSuper = true) |
||||
@TableName("t_custom_metric_stat") |
||||
@ApiModel(value = "CustomMetricStat", description = "自定义指标统计数据") |
||||
public class CustomMetricStat extends BaseEntity implements Serializable { |
||||
|
||||
private static final long serialVersionUID = 1L; |
||||
|
||||
/** |
||||
* 配置ID |
||||
*/ |
||||
@ApiModelProperty("配置ID") |
||||
@JsonProperty("configId") |
||||
private Long configId; |
||||
|
||||
/** |
||||
* 统计日期 |
||||
*/ |
||||
@ApiModelProperty("统计日期") |
||||
@JsonProperty("statDate") |
||||
private Date statDate; |
||||
|
||||
/** |
||||
* 统计值 |
||||
*/ |
||||
@ApiModelProperty("统计值") |
||||
@JsonProperty("statValue") |
||||
private BigDecimal statValue; |
||||
|
||||
/** |
||||
* 统计对象ID |
||||
*/ |
||||
@ApiModelProperty("统计对象ID") |
||||
@JsonProperty("statObjectId") |
||||
private Long statObjectId; |
||||
|
||||
/** |
||||
* 统计对象名称 |
||||
*/ |
||||
@ApiModelProperty("统计对象名称") |
||||
@JsonProperty("statObjectName") |
||||
private String statObjectName; |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
package org.springblade.lims.mapper; |
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
import org.springblade.lims.entry.CustomMetricConfig; |
||||
|
||||
/** |
||||
* 自定义指标配置 Mapper |
||||
* |
||||
* @author swj |
||||
* @since 2026-06-01 |
||||
*/ |
||||
public interface CustomMetricConfigMapper extends BaseMapper<CustomMetricConfig> { |
||||
|
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
package org.springblade.lims.mapper; |
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
import org.springblade.lims.entry.CustomMetricStat; |
||||
|
||||
/** |
||||
* 自定义指标统计数据 Mapper |
||||
* |
||||
* @author swj |
||||
* @since 2026-06-01 |
||||
*/ |
||||
public interface CustomMetricStatMapper extends BaseMapper<CustomMetricStat> { |
||||
|
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
package org.springblade.lims.service; |
||||
|
||||
import org.springblade.core.mp.base.BaseService; |
||||
import org.springblade.lims.entry.CustomMetricConfig; |
||||
|
||||
/** |
||||
* 自定义指标配置 服务类 |
||||
* |
||||
* @author swj |
||||
* @since 2026-06-01 |
||||
*/ |
||||
public interface ICustomMetricConfigService extends BaseService<CustomMetricConfig> { |
||||
|
||||
} |
||||
@ -0,0 +1,38 @@ |
||||
package org.springblade.lims.service; |
||||
|
||||
import org.springblade.core.mp.base.BaseService; |
||||
import org.springblade.lims.entry.CustomMetricStat; |
||||
|
||||
import java.util.Date; |
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* 自定义指标统计数据 服务类 |
||||
* |
||||
* @author swj |
||||
* @since 2026-06-01 |
||||
*/ |
||||
public interface ICustomMetricStatService extends BaseService<CustomMetricStat> { |
||||
|
||||
/** |
||||
* 执行单个配置的计算 |
||||
* |
||||
* @param configId 配置ID |
||||
* @return 执行结果 |
||||
*/ |
||||
Map<String, Object> executeCalculation(Long configId); |
||||
|
||||
/** |
||||
* 执行所有启用的配置 |
||||
*/ |
||||
void executeAllConfigs(); |
||||
|
||||
/** |
||||
* 删除已有计算结果(幂等性) |
||||
* |
||||
* @param configId 配置ID |
||||
* @param periodStart 周期开始时间 |
||||
* @param periodEnd 周期结束时间 |
||||
*/ |
||||
void deleteExistingResults(Long configId, Date periodStart, Date periodEnd); |
||||
} |
||||
@ -0,0 +1,20 @@ |
||||
package org.springblade.lims.service.impl; |
||||
|
||||
import lombok.AllArgsConstructor; |
||||
import org.springblade.core.mp.base.BaseServiceImpl; |
||||
import org.springblade.lims.entry.CustomMetricConfig; |
||||
import org.springblade.lims.mapper.CustomMetricConfigMapper; |
||||
import org.springblade.lims.service.ICustomMetricConfigService; |
||||
import org.springframework.stereotype.Service; |
||||
|
||||
/** |
||||
* 自定义指标配置 服务实现类 |
||||
* |
||||
* @author swj |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Service |
||||
@AllArgsConstructor |
||||
public class CustomMetricConfigServiceImpl extends BaseServiceImpl<CustomMetricConfigMapper, CustomMetricConfig> implements ICustomMetricConfigService { |
||||
|
||||
} |
||||
@ -0,0 +1,412 @@ |
||||
package org.springblade.lims.service.impl; |
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
import lombok.AllArgsConstructor; |
||||
import lombok.extern.slf4j.Slf4j; |
||||
import org.springblade.core.mp.base.BaseServiceImpl; |
||||
import org.springblade.core.tool.api.R; |
||||
import org.springblade.lims.entry.CustomMetricConfig; |
||||
import org.springblade.lims.entry.CustomMetricStat; |
||||
import org.springblade.lims.entry.Examine; |
||||
import org.springblade.lims.entry.ReagentUseLog; |
||||
import org.springblade.lims.mapper.CustomMetricStatMapper; |
||||
import org.springblade.lims.mapper.ExamineMapper; |
||||
import org.springblade.lims.mapper.ReagentUseLogMapper; |
||||
import org.springblade.lims.service.ICustomMetricConfigService; |
||||
import org.springblade.lims.service.ICustomMetricStatService; |
||||
import org.springblade.system.feign.ISysClient; |
||||
import org.springblade.system.user.entity.User; |
||||
import org.springblade.system.user.feign.IUserClient; |
||||
import org.springframework.stereotype.Service; |
||||
import org.springframework.transaction.annotation.Transactional; |
||||
|
||||
import java.math.BigDecimal; |
||||
import java.math.RoundingMode; |
||||
import java.util.ArrayList; |
||||
import java.util.Calendar; |
||||
import java.util.Date; |
||||
import java.util.HashMap; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
import java.util.stream.Collectors; |
||||
|
||||
/** |
||||
* 自定义指标统计数据 服务实现类 |
||||
* |
||||
* @author swj |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Slf4j |
||||
@Service |
||||
@AllArgsConstructor |
||||
public class CustomMetricStatServiceImpl extends BaseServiceImpl<CustomMetricStatMapper, CustomMetricStat> implements ICustomMetricStatService { |
||||
|
||||
private final ICustomMetricConfigService configService; |
||||
private final ExamineMapper examineMapper; |
||||
private final ReagentUseLogMapper reagentUseLogMapper; |
||||
private final IUserClient userClient; |
||||
private final ISysClient sysClient; |
||||
|
||||
@Override |
||||
@Transactional(rollbackFor = Exception.class) |
||||
public Map<String, Object> executeCalculation(Long configId) { |
||||
// 1. 获取配置
|
||||
CustomMetricConfig config = configService.getById(configId); |
||||
if (config == null) { |
||||
throw new RuntimeException("配置不存在: " + configId); |
||||
} |
||||
if (!Integer.valueOf(1).equals(config.getEnabled())) { |
||||
throw new RuntimeException("配置未启用: " + configId); |
||||
} |
||||
|
||||
// 2. 计算周期起止时间
|
||||
Date[] period = calculatePeriod(config.getPeriod()); |
||||
Date periodStart = period[0]; |
||||
Date periodEnd = period[1]; |
||||
|
||||
log.info("执行计算 config[{}] statField={} period={} range=[{} - {}]", |
||||
configId, config.getStatField(), config.getPeriod(), periodStart, periodEnd); |
||||
|
||||
// 3. 幂等清理
|
||||
deleteExistingResults(configId, periodStart, periodEnd); |
||||
|
||||
// 4. 计算并保存统计结果
|
||||
List<CustomMetricStat> results = calculateStats(config, periodStart, periodEnd); |
||||
|
||||
if (!results.isEmpty()) { |
||||
saveBatch(results); |
||||
} |
||||
|
||||
// 5. 返回结果摘要
|
||||
Map<String, Object> result = new HashMap<>(8); |
||||
result.put("configId", configId); |
||||
result.put("configName", config.getConfigName()); |
||||
result.put("statField", config.getStatField()); |
||||
result.put("period", config.getPeriod()); |
||||
result.put("aggregation", config.getAggregation()); |
||||
result.put("periodStart", periodStart); |
||||
result.put("periodEnd", periodEnd); |
||||
result.put("recordCount", results.size()); |
||||
return result; |
||||
} |
||||
|
||||
@Override |
||||
public void executeAllConfigs() { |
||||
List<CustomMetricConfig> configs = configService.lambdaQuery() |
||||
.eq(CustomMetricConfig::getEnabled, 1) |
||||
.list(); |
||||
log.info("执行所有启用的配置, 共 {} 条", configs.size()); |
||||
for (CustomMetricConfig config : configs) { |
||||
try { |
||||
executeCalculation(config.getId()); |
||||
log.info("配置计算完成 [{}]: {}", config.getId(), config.getConfigName()); |
||||
} catch (Exception e) { |
||||
log.error("配置计算失败 [{}]: {} - {}", config.getId(), config.getConfigName(), e.getMessage(), e); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void deleteExistingResults(Long configId, Date periodStart, Date periodEnd) { |
||||
boolean removed = lambdaUpdate() |
||||
.eq(CustomMetricStat::getConfigId, configId) |
||||
.ge(CustomMetricStat::getStatDate, periodStart) |
||||
.le(CustomMetricStat::getStatDate, periodEnd) |
||||
.remove(); |
||||
if (removed) { |
||||
log.info("清除已有结果 configId={}", configId); |
||||
} |
||||
} |
||||
|
||||
// ==================== 周期计算 ====================
|
||||
|
||||
/** |
||||
* 根据周期类型计算起止时间 |
||||
*/ |
||||
private Date[] calculatePeriod(String period) { |
||||
Calendar cal = Calendar.getInstance(); |
||||
cal.set(Calendar.HOUR_OF_DAY, 0); |
||||
cal.set(Calendar.MINUTE, 0); |
||||
cal.set(Calendar.SECOND, 0); |
||||
cal.set(Calendar.MILLISECOND, 0); |
||||
|
||||
switch (period) { |
||||
case "day": |
||||
break; |
||||
case "week": |
||||
cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY); |
||||
break; |
||||
case "month": |
||||
cal.set(Calendar.DAY_OF_MONTH, 1); |
||||
break; |
||||
case "year": |
||||
cal.set(Calendar.DAY_OF_YEAR, 1); |
||||
break; |
||||
default: |
||||
throw new RuntimeException("未知统计周期: " + period); |
||||
} |
||||
|
||||
Date periodStart = cal.getTime(); |
||||
Date periodEnd = new Date(); |
||||
return new Date[]{periodStart, periodEnd}; |
||||
} |
||||
|
||||
// ==================== 统计对象路由 ====================
|
||||
|
||||
private List<CustomMetricStat> calculateStats(CustomMetricConfig config, Date periodStart, Date periodEnd) { |
||||
String statObject = config.getStatObject(); |
||||
if ("user".equals(statObject)) { |
||||
return calculateUserStats(config, periodStart, periodEnd); |
||||
} else if ("dept".equals(statObject)) { |
||||
return calculateDeptStats(config, periodStart, periodEnd); |
||||
} |
||||
throw new RuntimeException("未知统计对象: " + statObject); |
||||
} |
||||
|
||||
// ==================== 按用户统计 ====================
|
||||
|
||||
private List<CustomMetricStat> calculateUserStats(CustomMetricConfig config, Date periodStart, Date periodEnd) { |
||||
String statField = config.getStatField(); |
||||
|
||||
if ("reagentUsage".equals(statField)) { |
||||
return calculateReagentUsageByUser(config, periodStart, periodEnd); |
||||
} |
||||
|
||||
// 查询指定周期内的 Examine 记录
|
||||
LambdaQueryWrapper<Examine> wrapper = buildExamineQueryWrapper(statField, periodStart, periodEnd); |
||||
List<Examine> examines = examineMapper.selectList(wrapper); |
||||
|
||||
// 按 examineBy 分组
|
||||
Map<String, List<Examine>> byUser = examines.stream() |
||||
.filter(e -> e.getExamineBy() != null && !e.getExamineBy().trim().isEmpty()) |
||||
.collect(Collectors.groupingBy(Examine::getExamineBy)); |
||||
|
||||
// 计算每个用户的统计值
|
||||
Map<String, BigDecimal> userValues = new HashMap<>(byUser.size() * 2); |
||||
for (Map.Entry<String, List<Examine>> entry : byUser.entrySet()) { |
||||
userValues.put(entry.getKey(), BigDecimal.valueOf(entry.getValue().size())); |
||||
} |
||||
|
||||
return buildStatResults(config, periodStart, userValues); |
||||
} |
||||
|
||||
/** |
||||
* 按试剂使用(按用户)统计 |
||||
*/ |
||||
private List<CustomMetricStat> calculateReagentUsageByUser(CustomMetricConfig config, Date periodStart, Date periodEnd) { |
||||
LambdaQueryWrapper<ReagentUseLog> wrapper = new LambdaQueryWrapper<ReagentUseLog>() |
||||
.ge(ReagentUseLog::getCreateTime, periodStart) |
||||
.le(ReagentUseLog::getCreateTime, periodEnd); |
||||
|
||||
List<ReagentUseLog> logs = reagentUseLogMapper.selectList(wrapper); |
||||
|
||||
// 按创建用户分组并汇总用量
|
||||
Map<String, BigDecimal> userValues = logs.stream() |
||||
.filter(l -> l.getCreateUser() != null) |
||||
.collect(Collectors.groupingBy( |
||||
l -> String.valueOf(l.getCreateUser()), |
||||
Collectors.reducing(BigDecimal.ZERO, |
||||
l -> BigDecimal.valueOf(l.getCount() != null ? l.getCount() : 0), |
||||
BigDecimal::add) |
||||
)); |
||||
|
||||
return buildStatResults(config, periodStart, userValues); |
||||
} |
||||
|
||||
// ==================== 按部门统计 ====================
|
||||
|
||||
private List<CustomMetricStat> calculateDeptStats(CustomMetricConfig config, Date periodStart, Date periodEnd) { |
||||
String statField = config.getStatField(); |
||||
|
||||
if ("reagentUsage".equals(statField)) { |
||||
return calculateReagentUsageByDept(config, periodStart, periodEnd); |
||||
} |
||||
|
||||
// 查询指定周期内的 Examine 记录
|
||||
LambdaQueryWrapper<Examine> wrapper = buildExamineQueryWrapper(statField, periodStart, periodEnd); |
||||
wrapper.isNotNull(Examine::getDeptId); |
||||
List<Examine> examines = examineMapper.selectList(wrapper); |
||||
|
||||
// 按 deptId 分组
|
||||
Map<Long, List<Examine>> byDept = examines.stream() |
||||
.filter(e -> e.getDeptId() != null) |
||||
.collect(Collectors.groupingBy(Examine::getDeptId)); |
||||
|
||||
// 计算每个部门的统计值
|
||||
Map<String, BigDecimal> deptValues = new HashMap<>(byDept.size() * 2); |
||||
for (Map.Entry<Long, List<Examine>> entry : byDept.entrySet()) { |
||||
deptValues.put(String.valueOf(entry.getKey()), BigDecimal.valueOf(entry.getValue().size())); |
||||
} |
||||
|
||||
return buildDeptStatResults(config, periodStart, deptValues); |
||||
} |
||||
|
||||
/** |
||||
* 按试剂使用(按部门)统计 |
||||
*/ |
||||
private List<CustomMetricStat> calculateReagentUsageByDept(CustomMetricConfig config, Date periodStart, Date periodEnd) { |
||||
LambdaQueryWrapper<ReagentUseLog> wrapper = new LambdaQueryWrapper<ReagentUseLog>() |
||||
.ge(ReagentUseLog::getCreateTime, periodStart) |
||||
.le(ReagentUseLog::getCreateTime, periodEnd); |
||||
|
||||
List<ReagentUseLog> logs = reagentUseLogMapper.selectList(wrapper); |
||||
|
||||
// 按创建部门分组并汇总用量
|
||||
Map<String, BigDecimal> deptValues = logs.stream() |
||||
.filter(l -> l.getCreateDept() != null) |
||||
.collect(Collectors.groupingBy( |
||||
l -> String.valueOf(l.getCreateDept()), |
||||
Collectors.reducing(BigDecimal.ZERO, |
||||
l -> BigDecimal.valueOf(l.getCount() != null ? l.getCount() : 0), |
||||
BigDecimal::add) |
||||
)); |
||||
|
||||
return buildDeptStatResults(config, periodStart, deptValues); |
||||
} |
||||
|
||||
// ==================== 查询条件构建 ====================
|
||||
|
||||
/** |
||||
* 根据统计字段类型构建 Examine 查询条件 |
||||
*/ |
||||
private LambdaQueryWrapper<Examine> buildExamineQueryWrapper(String statField, Date periodStart, Date periodEnd) { |
||||
LambdaQueryWrapper<Examine> wrapper = new LambdaQueryWrapper<Examine>(); |
||||
|
||||
switch (statField) { |
||||
case "detectCount": |
||||
// 已完成检测(在周期内完成的)
|
||||
wrapper.eq(Examine::getIsFinished, "1") |
||||
.ge(Examine::getFinishTime, periodStart) |
||||
.le(Examine::getFinishTime, periodEnd); |
||||
break; |
||||
case "taskCount": |
||||
// 分配的任务(在周期内领取的)
|
||||
wrapper.ge(Examine::getReceiveTime, periodStart) |
||||
.le(Examine::getReceiveTime, periodEnd); |
||||
break; |
||||
case "delayCount": |
||||
// 超时未完成(要求完成时间在周期内,但未完成)
|
||||
wrapper.ne(Examine::getIsFinished, "1") |
||||
.ge(Examine::getDemandCompletionTime, periodStart) |
||||
.le(Examine::getDemandCompletionTime, periodEnd); |
||||
break; |
||||
default: |
||||
throw new RuntimeException("未知统计字段: " + statField); |
||||
} |
||||
return wrapper; |
||||
} |
||||
|
||||
// ==================== 结果构建 ====================
|
||||
|
||||
/** |
||||
* 按 sum/avg 聚合方式构建用户统计结果 |
||||
*/ |
||||
private List<CustomMetricStat> buildStatResults(CustomMetricConfig config, Date statDate, |
||||
Map<String, BigDecimal> userValues) { |
||||
List<CustomMetricStat> results = new ArrayList<>(); |
||||
|
||||
if ("sum".equals(config.getAggregation())) { |
||||
// 每人一条记录
|
||||
for (Map.Entry<String, BigDecimal> entry : userValues.entrySet()) { |
||||
String userIdStr = entry.getKey(); |
||||
try { |
||||
Long userId = Long.parseLong(userIdStr); |
||||
String userName = resolveUserName(userId); |
||||
results.add(createStatEntity(config.getId(), statDate, entry.getValue(), userId, userName)); |
||||
} catch (NumberFormatException e) { |
||||
log.warn("跳过无效用户ID: {}", userIdStr); |
||||
} |
||||
} |
||||
} else if ("avg".equals(config.getAggregation())) { |
||||
// 计算全员平均值
|
||||
if (!userValues.isEmpty()) { |
||||
BigDecimal total = userValues.values().stream() |
||||
.reduce(BigDecimal.ZERO, BigDecimal::add); |
||||
BigDecimal avg = total.divide(BigDecimal.valueOf(userValues.size()), 2, RoundingMode.HALF_UP); |
||||
results.add(createStatEntity(config.getId(), statDate, avg, 0L, "全员平均")); |
||||
} |
||||
} |
||||
|
||||
return results; |
||||
} |
||||
|
||||
/** |
||||
* 按 sum/avg 聚合方式构建部门统计结果 |
||||
*/ |
||||
private List<CustomMetricStat> buildDeptStatResults(CustomMetricConfig config, Date statDate, |
||||
Map<String, BigDecimal> deptValues) { |
||||
List<CustomMetricStat> results = new ArrayList<>(); |
||||
|
||||
if ("sum".equals(config.getAggregation())) { |
||||
// 每个部门一条记录
|
||||
for (Map.Entry<String, BigDecimal> entry : deptValues.entrySet()) { |
||||
String deptIdStr = entry.getKey(); |
||||
try { |
||||
Long deptId = Long.parseLong(deptIdStr); |
||||
String deptName = resolveDeptName(deptId); |
||||
results.add(createStatEntity(config.getId(), statDate, entry.getValue(), deptId, deptName)); |
||||
} catch (NumberFormatException e) { |
||||
log.warn("跳过无效部门ID: {}", deptIdStr); |
||||
} |
||||
} |
||||
} else if ("avg".equals(config.getAggregation())) { |
||||
// 计算全部门平均值
|
||||
if (!deptValues.isEmpty()) { |
||||
BigDecimal total = deptValues.values().stream() |
||||
.reduce(BigDecimal.ZERO, BigDecimal::add); |
||||
BigDecimal avg = total.divide(BigDecimal.valueOf(deptValues.size()), 2, RoundingMode.HALF_UP); |
||||
results.add(createStatEntity(config.getId(), statDate, avg, 0L, "部门平均")); |
||||
} |
||||
} |
||||
|
||||
return results; |
||||
} |
||||
|
||||
/** |
||||
* 创建统计实体 |
||||
*/ |
||||
private CustomMetricStat createStatEntity(Long configId, Date statDate, BigDecimal statValue, |
||||
Long statObjectId, String statObjectName) { |
||||
CustomMetricStat stat = new CustomMetricStat(); |
||||
stat.setConfigId(configId); |
||||
stat.setStatDate(statDate); |
||||
stat.setStatValue(statValue); |
||||
stat.setStatObjectId(statObjectId); |
||||
stat.setStatObjectName(statObjectName); |
||||
return stat; |
||||
} |
||||
|
||||
// ==================== 名称解析 ====================
|
||||
|
||||
/** |
||||
* 根据用户ID查询用户名 |
||||
*/ |
||||
private String resolveUserName(Long userId) { |
||||
try { |
||||
R<User> result = userClient.userInfoById(userId); |
||||
if (result.isSuccess() && result.getData() != null) { |
||||
String name = result.getData().getName(); |
||||
String realName = result.getData().getRealName(); |
||||
return realName != null && !realName.trim().isEmpty() ? realName : name; |
||||
} |
||||
} catch (Exception e) { |
||||
log.warn("查询用户名称失败 userId={}: {}", userId, e.getMessage()); |
||||
} |
||||
return String.valueOf(userId); |
||||
} |
||||
|
||||
/** |
||||
* 根据部门ID查询部门名称 |
||||
*/ |
||||
private String resolveDeptName(Long deptId) { |
||||
try { |
||||
R<String> result = sysClient.getDeptName(deptId); |
||||
if (result.isSuccess() && result.getData() != null) { |
||||
return result.getData(); |
||||
} |
||||
} catch (Exception e) { |
||||
log.warn("查询部门名称失败 deptId={}: {}", deptId, e.getMessage()); |
||||
} |
||||
return String.valueOf(deptId); |
||||
} |
||||
} |
||||
@ -0,0 +1,232 @@ |
||||
package org.springblade.monitor.controller; |
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
import com.baomidou.mybatisplus.core.metadata.IPage; |
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; |
||||
import io.swagger.annotations.Api; |
||||
import io.swagger.annotations.ApiOperation; |
||||
import io.swagger.annotations.ApiParam; |
||||
import lombok.AllArgsConstructor; |
||||
import lombok.extern.slf4j.Slf4j; |
||||
import org.springblade.core.boot.ctrl.BladeController; |
||||
import org.springblade.core.mp.support.Condition; |
||||
import org.springblade.core.mp.support.Query; |
||||
import org.springblade.core.secure.utils.AuthUtil; |
||||
import org.springblade.core.tool.api.R; |
||||
import org.springblade.core.tool.utils.DateUtil; |
||||
import org.springblade.core.tool.utils.Func; |
||||
import org.springblade.lims.entry.CustomMetricConfig; |
||||
import org.springblade.lims.entry.CustomMetricStat; |
||||
import org.springblade.lims.service.ICustomMetricConfigService; |
||||
import org.springblade.lims.service.ICustomMetricStatService; |
||||
import org.springframework.web.bind.annotation.*; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.Date; |
||||
import java.util.HashMap; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* 自定义指标配置 控制器 |
||||
* |
||||
* @author swj |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Slf4j |
||||
@RestController |
||||
@AllArgsConstructor |
||||
@RequestMapping("/monitor/metric-config") |
||||
@Api(value = "自定义指标配置", tags = "自定义指标配置") |
||||
public class CustomMetricConfigController extends BladeController { |
||||
|
||||
private final ICustomMetricConfigService service; |
||||
|
||||
private final ICustomMetricStatService customMetricStatService; |
||||
|
||||
/** |
||||
* 分页列表 |
||||
*/ |
||||
@GetMapping("list") |
||||
@ApiOperationSupport(order = 2) |
||||
@ApiOperation(value = "分页列表", notes = "分页列表") |
||||
public R<IPage<CustomMetricConfig>> list(CustomMetricConfig entry, Query query) { |
||||
LambdaQueryWrapper<CustomMetricConfig> wrapper = new LambdaQueryWrapper<>(); |
||||
if (entry.getConfigName() != null) { |
||||
wrapper.like(CustomMetricConfig::getConfigName, entry.getConfigName()); |
||||
} |
||||
wrapper.orderByDesc(CustomMetricConfig::getCreateTime); |
||||
IPage<CustomMetricConfig> page = service.page(Condition.getPage(query), wrapper); |
||||
return R.data(page); |
||||
} |
||||
|
||||
/** |
||||
* 新增 |
||||
*/ |
||||
@PostMapping("save") |
||||
@ApiOperation(value = "新增", notes = "新增") |
||||
public R save(@RequestBody CustomMetricConfig entry) { |
||||
entry.setCreateTime(new Date()); |
||||
entry.setUpdateTime(new Date()); |
||||
return R.status(service.save(entry)); |
||||
} |
||||
|
||||
/** |
||||
* 修改 |
||||
*/ |
||||
@PostMapping("update") |
||||
@ApiOperation(value = "修改", notes = "修改") |
||||
public R update(@RequestBody CustomMetricConfig entry) { |
||||
return R.status(service.updateById(entry)); |
||||
} |
||||
|
||||
/** |
||||
* 逻辑删除 |
||||
*/ |
||||
@PostMapping("deleteByIds") |
||||
@ApiOperation(value = "逻辑删除", notes = "传入ids") |
||||
public R deleteByIds(@ApiParam(value = "主键集合", required = true) @RequestParam String ids) { |
||||
return R.status(service.deleteLogic(Func.toLongList(ids))); |
||||
} |
||||
|
||||
/** |
||||
* 手动触发统计计算 |
||||
* <p> |
||||
* 跳过 DictBizCache 定时开关判断,直接执行计算。 |
||||
* 可选传入 configId 执行单个配置,为空则执行所有启用的配置。 |
||||
* </p> |
||||
*/ |
||||
@PostMapping("trigger-calculation") |
||||
@ApiOperation(value = "手动触发计算", notes = "手动触发自定义指标统计计算,跳过定时开关判断。可选指定配置ID,为空则执行所有启用配置。") |
||||
public R<Map<String, Object>> triggerCalculation( |
||||
@ApiParam(value = "配置ID,为空则执行所有启用配置") |
||||
@RequestParam(required = false) Long configId) { |
||||
// Admin-only auth check
|
||||
// if (!AuthUtil.isAdministrator()) {
|
||||
// return R.fail("仅管理员可执行此操作");
|
||||
// }
|
||||
|
||||
List<Map<String, Object>> details = new ArrayList<>(); |
||||
int successCount = 0; |
||||
int failCount = 0; |
||||
|
||||
if (configId != null) { |
||||
// 执行单个配置
|
||||
try { |
||||
Map<String, Object> detail = customMetricStatService.executeCalculation(configId); |
||||
detail.put("status", "success"); |
||||
successCount = 1; |
||||
details.add(detail); |
||||
} catch (Exception e) { |
||||
failCount = 1; |
||||
Map<String, Object> errorDetail = new HashMap<>(4); |
||||
errorDetail.put("configId", configId); |
||||
errorDetail.put("status", "fail"); |
||||
errorDetail.put("error", e.getMessage()); |
||||
details.add(errorDetail); |
||||
log.error("手动触发计算失败 configId={}", configId, e); |
||||
} |
||||
} else { |
||||
// 执行所有启用的配置
|
||||
List<CustomMetricConfig> configs = service.lambdaQuery() |
||||
.eq(CustomMetricConfig::getEnabled, 1) |
||||
.list(); |
||||
for (CustomMetricConfig config : configs) { |
||||
try { |
||||
Map<String, Object> detail = customMetricStatService.executeCalculation(config.getId()); |
||||
detail.put("status", "success"); |
||||
successCount++; |
||||
details.add(detail); |
||||
} catch (Exception e) { |
||||
failCount++; |
||||
Map<String, Object> errorDetail = new HashMap<>(4); |
||||
errorDetail.put("configId", config.getId()); |
||||
errorDetail.put("configName", config.getConfigName()); |
||||
errorDetail.put("status", "fail"); |
||||
errorDetail.put("error", e.getMessage()); |
||||
details.add(errorDetail); |
||||
log.error("手动触发计算失败 configId={} configName={}", config.getId(), config.getConfigName(), e); |
||||
} |
||||
} |
||||
} |
||||
|
||||
Map<String, Object> result = new HashMap<>(8); |
||||
result.put("successCount", successCount); |
||||
result.put("failCount", failCount); |
||||
result.put("total", successCount + failCount); |
||||
result.put("details", details); |
||||
return R.data(result); |
||||
} |
||||
|
||||
/** |
||||
* 统计结果分页列表 |
||||
* <p> |
||||
* 分页查询自定义指标统计结果,附带配置名称。 |
||||
* </p> |
||||
*/ |
||||
@GetMapping("metric-stat/list") |
||||
@ApiOperationSupport(order = 5) |
||||
@ApiOperation(value = "统计结果分页列表", notes = "分页查询自定义指标统计结果") |
||||
public R<Map<String, Object>> metricStatList( |
||||
@ApiParam(value = "配置ID") @RequestParam(required = false) Long configId, |
||||
@ApiParam(value = "开始日期(yyyy-MM-dd)") @RequestParam(required = false) String startDateStr, |
||||
@ApiParam(value = "结束日期(yyyy-MM-dd)") @RequestParam(required = false) String endDateStr, |
||||
@ApiParam(value = "统计对象ID") @RequestParam(required = false) Long statObjectId, |
||||
Query query) { |
||||
Date startDate = parseDateStr(startDateStr); |
||||
Date endDate = parseDateStr(endDateStr); |
||||
LambdaQueryWrapper<CustomMetricStat> wrapper = new LambdaQueryWrapper<>(); |
||||
if (configId != null) { |
||||
wrapper.eq(CustomMetricStat::getConfigId, configId); |
||||
} |
||||
if (startDate != null) { |
||||
wrapper.ge(CustomMetricStat::getStatDate, startDate); |
||||
} |
||||
if (endDate != null) { |
||||
wrapper.le(CustomMetricStat::getStatDate, endDate); |
||||
} |
||||
if (statObjectId != null) { |
||||
wrapper.eq(CustomMetricStat::getStatObjectId, statObjectId); |
||||
} |
||||
wrapper.orderByDesc(CustomMetricStat::getCreateTime); |
||||
IPage<CustomMetricStat> page = customMetricStatService.page(Condition.getPage(query), wrapper); |
||||
|
||||
// Enrich records with config name
|
||||
List<Map<String, Object>> records = new ArrayList<>(page.getRecords().size()); |
||||
for (CustomMetricStat stat : page.getRecords()) { |
||||
Map<String, Object> record = new HashMap<>(8); |
||||
record.put("id", stat.getId()); |
||||
record.put("configId", stat.getConfigId()); |
||||
if (stat.getConfigId() != null) { |
||||
CustomMetricConfig config = service.getById(stat.getConfigId()); |
||||
record.put("configName", config != null ? config.getConfigName() : ""); |
||||
} else { |
||||
record.put("configName", ""); |
||||
} |
||||
record.put("statDate", stat.getStatDate()); |
||||
record.put("statValue", stat.getStatValue()); |
||||
record.put("statObjectId", stat.getStatObjectId()); |
||||
record.put("statObjectName", stat.getStatObjectName()); |
||||
record.put("createTime", stat.getCreateTime()); |
||||
records.add(record); |
||||
} |
||||
|
||||
Map<String, Object> result = new HashMap<>(8); |
||||
result.put("records", records); |
||||
result.put("total", page.getTotal()); |
||||
result.put("size", page.getSize()); |
||||
result.put("current", page.getCurrent()); |
||||
return R.data(result); |
||||
} |
||||
|
||||
/** |
||||
* 将 yyyy-MM-dd 字符串解析为 Date,空字符串或 null 返回 null |
||||
*/ |
||||
private static Date parseDateStr(String dateStr) { |
||||
if (dateStr == null || dateStr.trim().isEmpty()) { |
||||
return null; |
||||
} |
||||
return DateUtil.parse(dateStr, DateUtil.PATTERN_DATE); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,82 @@ |
||||
package org.springblade.monitor.controller; |
||||
|
||||
import io.swagger.annotations.Api; |
||||
import io.swagger.annotations.ApiOperation; |
||||
import io.swagger.annotations.ApiParam; |
||||
import lombok.AllArgsConstructor; |
||||
import org.springblade.core.boot.ctrl.BladeController; |
||||
import org.springblade.core.tool.api.R; |
||||
import org.springblade.monitor.service.MonitorService; |
||||
import org.springblade.monitor.vo.MonitorVO; |
||||
import org.springblade.monitor.vo.ServiceStatusVO; |
||||
import org.springblade.monitor.vo.TrendPointVO; |
||||
import org.springframework.web.bind.annotation.*; |
||||
|
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 系统监控 控制器 |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@RestController |
||||
@AllArgsConstructor |
||||
@RequestMapping("/monitor") |
||||
@Api(value = "系统监控", tags = "系统监控") |
||||
public class MonitorController extends BladeController { |
||||
|
||||
private final MonitorService monitorService; |
||||
|
||||
/** |
||||
* 系统监控指标 |
||||
* <p> |
||||
* 获取系统 CPU、JVM 内存、物理内存、磁盘使用率、网络延迟等指标。 |
||||
* 每次调用会自动更新趋势数据缓冲区。 |
||||
* </p> |
||||
*/ |
||||
@GetMapping("/system") |
||||
@ApiOperation(value = "系统监控指标", notes = "获取系统 CPU、内存、磁盘等指标,同时更新趋势缓冲区") |
||||
public R<MonitorVO> system() { |
||||
MonitorVO metrics = monitorService.getSystemMetrics(); |
||||
return R.data(metrics); |
||||
} |
||||
|
||||
/** |
||||
* 服务健康状态列表 |
||||
* <p> |
||||
* 检查 Java 应用、MySQL、Redis 等服务健康状态。 |
||||
* 每次调用前会自动采集系统指标以更新趋势缓冲区。 |
||||
* </p> |
||||
*/ |
||||
@GetMapping("/services") |
||||
@ApiOperation(value = "服务健康状态", notes = "检查 Java、MySQL、Redis 等服务健康状态") |
||||
public R<List<ServiceStatusVO>> services() { |
||||
// 采集系统指标以更新趋势缓冲区
|
||||
monitorService.getSystemMetrics(); |
||||
List<ServiceStatusVO> statusList = monitorService.getServiceStatus(); |
||||
return R.data(statusList); |
||||
} |
||||
|
||||
/** |
||||
* 趋势数据 |
||||
* <p> |
||||
* 获取指定指标的最近趋势数据。 |
||||
* type 可选值: cpu, memory, disk, network |
||||
* 每次调用前会自动采集系统指标以更新趋势缓冲区。 |
||||
* </p> |
||||
*/ |
||||
@GetMapping("/trend") |
||||
@ApiOperation(value = "趋势数据", notes = "获取指定指标的最近趋势数据,type 可选值: cpu / memory / disk / network") |
||||
public R<List<TrendPointVO>> trend( |
||||
@ApiParam(value = "指标类型 (cpu / memory / disk / network)", required = true) |
||||
@RequestParam String type, |
||||
@ApiParam(value = "返回数据点数,默认 60") |
||||
@RequestParam(defaultValue = "60") int points) { |
||||
// 采集系统指标以更新趋势缓冲区
|
||||
monitorService.getSystemMetrics(); |
||||
List<TrendPointVO> trend = monitorService.getTrend(type, points); |
||||
return R.data(trend); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,62 @@ |
||||
package org.springblade.monitor.controller; |
||||
|
||||
import io.swagger.annotations.Api; |
||||
import io.swagger.annotations.ApiOperation; |
||||
import io.swagger.annotations.ApiParam; |
||||
import lombok.AllArgsConstructor; |
||||
import org.springblade.core.boot.ctrl.BladeController; |
||||
import org.springblade.core.tool.api.R; |
||||
import org.springblade.core.tool.utils.DateUtil; |
||||
import org.springblade.monitor.service.PersonnelWorkloadService; |
||||
import org.springblade.monitor.vo.WorkloadSummaryVO; |
||||
import org.springframework.web.bind.annotation.GetMapping; |
||||
import org.springframework.web.bind.annotation.RequestMapping; |
||||
import org.springframework.web.bind.annotation.RequestParam; |
||||
import org.springframework.web.bind.annotation.RestController; |
||||
|
||||
import java.util.Date; |
||||
|
||||
/** |
||||
* 人员工作量统计 控制器 |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@RestController |
||||
@AllArgsConstructor |
||||
@RequestMapping("/monitor") |
||||
@Api(value = "人员工作量统计", tags = "人员工作量统计") |
||||
public class PersonnelWorkloadController extends BladeController { |
||||
|
||||
private final PersonnelWorkloadService personnelWorkloadService; |
||||
|
||||
/** |
||||
* 获取人员工作量统计 |
||||
* |
||||
* @param start 开始日期 (yyyy-MM-dd),可选 |
||||
* @param end 结束日期 (yyyy-MM-dd),可选 |
||||
* @param deptId 部门 ID,可选 |
||||
* @return 工作量汇总数据 |
||||
*/ |
||||
@GetMapping("workload") |
||||
@ApiOperation(value = "人员工作量统计", notes = "按人员维度统计检验工作量,包括完成率、延期率、平均耗时") |
||||
public R<WorkloadSummaryVO> workload( |
||||
@ApiParam(value = "开始日期(yyyy-MM-dd)") @RequestParam(required = false) String startStr, |
||||
@ApiParam(value = "结束日期(yyyy-MM-dd)") @RequestParam(required = false) String endStr, |
||||
@ApiParam(value = "部门ID") @RequestParam(required = false) Long deptId) { |
||||
Date start = parseDate(startStr); |
||||
Date end = parseDate(endStr); |
||||
WorkloadSummaryVO result = personnelWorkloadService.getWorkload(start, end, deptId); |
||||
return R.data(result); |
||||
} |
||||
|
||||
/** |
||||
* 将 yyyy-MM-dd 字符串解析为 Date,空字符串或 null 返回 null |
||||
*/ |
||||
private static Date parseDate(String dateStr) { |
||||
if (dateStr == null || dateStr.trim().isEmpty()) { |
||||
return null; |
||||
} |
||||
return DateUtil.parse(dateStr, DateUtil.PATTERN_DATE); |
||||
} |
||||
} |
||||
@ -0,0 +1,47 @@ |
||||
|
||||
package org.springblade.monitor.scheduled; |
||||
|
||||
import lombok.AllArgsConstructor; |
||||
import lombok.extern.slf4j.Slf4j; |
||||
import org.springblade.lims.service.ICustomMetricStatService; |
||||
import org.springblade.system.cache.DictBizCache; |
||||
import org.springblade.system.enums.DictBizEnum; |
||||
import org.springframework.scheduling.annotation.Scheduled; |
||||
import org.springframework.stereotype.Component; |
||||
|
||||
import java.time.LocalDateTime; |
||||
|
||||
/** |
||||
* 监控模块定时任务 |
||||
* |
||||
* @author swj |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Slf4j |
||||
@Component |
||||
@AllArgsConstructor |
||||
public class MonitorScheduledTasks { |
||||
|
||||
private final ICustomMetricStatService customMetricStatService; |
||||
|
||||
/** |
||||
* 每晚 00:00 执行自定义指标统计 |
||||
*/ |
||||
@Scheduled(cron = "0 0 0 * * ?") |
||||
public void executeMetricStatAtMidnight() { |
||||
// ===================== 核心:开关判断 =====================
|
||||
String enabled = DictBizCache.getKey(DictBizEnum.SCHEDULED_TASK.getName(), "openMonitorScheduled"); |
||||
if ("1".equals(enabled)) { |
||||
log.info("===== 指标统计定时任务已关闭,不执行 ====="); |
||||
return; |
||||
} |
||||
log.info("===== 凌晨0点,开始执行指标统计任务 =====: {}", LocalDateTime.now()); |
||||
try { |
||||
customMetricStatService.executeAllConfigs(); |
||||
log.info("===== 指标统计任务完成 =====: {}", LocalDateTime.now()); |
||||
} catch (Exception e) { |
||||
log.error("===== 指标统计任务异常 =====: {}", LocalDateTime.now(), e); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,50 @@ |
||||
package org.springblade.monitor.service; |
||||
|
||||
import org.springblade.monitor.vo.MonitorVO; |
||||
import org.springblade.monitor.vo.ServiceStatusVO; |
||||
import org.springblade.monitor.vo.TrendPointVO; |
||||
|
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 系统监控服务接口 |
||||
* <p> |
||||
* 提供系统指标采集、服务健康检查、网络延迟测量、趋势数据查询功能。 |
||||
* 所有数据均通过 JDK 内置 API 采集,无需第三方依赖。 |
||||
* </p> |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
public interface MonitorService { |
||||
|
||||
/** |
||||
* 获取系统监控指标(CPU、内存、磁盘) |
||||
* |
||||
* @return MonitorVO 包含完整的系统资源指标 |
||||
*/ |
||||
MonitorVO getSystemMetrics(); |
||||
|
||||
/** |
||||
* 检查各服务健康状态 |
||||
* |
||||
* @return 服务状态列表(Java 应用 / MySQL / Redis 等) |
||||
*/ |
||||
List<ServiceStatusVO> getServiceStatus(); |
||||
|
||||
/** |
||||
* 测量到目标主机的网络延迟 |
||||
* |
||||
* @return 延迟 (ms),失败时返回 -1 |
||||
*/ |
||||
Long getNetworkLatency(); |
||||
|
||||
/** |
||||
* 获取指定指标的最近趋势数据 |
||||
* |
||||
* @param type 指标类型 (cpu / memory / disk / network) |
||||
* @param points 返回的数据点数 |
||||
* @return 趋势数据点列表 |
||||
*/ |
||||
List<TrendPointVO> getTrend(String type, int points); |
||||
} |
||||
@ -0,0 +1,28 @@ |
||||
package org.springblade.monitor.service; |
||||
|
||||
import org.springblade.monitor.vo.WorkloadSummaryVO; |
||||
|
||||
import java.util.Date; |
||||
|
||||
/** |
||||
* 人员工作量统计服务接口 |
||||
* <p> |
||||
* 基于 Examine 检验记录,按人员维度统计工作量、完成率、延期率、平均耗时等指标。 |
||||
* 跨数据源查询:Examine (sxm_system) + blade_user / blade_dept (sxmlims)。 |
||||
* </p> |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
public interface PersonnelWorkloadService { |
||||
|
||||
/** |
||||
* 获取人员工作量统计 |
||||
* |
||||
* @param startDate 开始日期(基于 receiveTime 筛选,可选) |
||||
* @param endDate 结束日期(可选) |
||||
* @param deptId 部门 ID(可选) |
||||
* @return 工作量汇总 VO |
||||
*/ |
||||
WorkloadSummaryVO getWorkload(Date startDate, Date endDate, Long deptId); |
||||
} |
||||
@ -0,0 +1,488 @@ |
||||
package org.springblade.monitor.service.impl; |
||||
|
||||
import lombok.extern.slf4j.Slf4j; |
||||
import org.springblade.monitor.service.MonitorService; |
||||
import org.springblade.monitor.util.MonitorUtil; |
||||
import org.springblade.monitor.vo.DiskInfoVO; |
||||
import org.springblade.monitor.vo.MonitorVO; |
||||
import org.springblade.monitor.vo.ServiceStatusVO; |
||||
import org.springblade.monitor.vo.TrendPointVO; |
||||
import org.springframework.beans.factory.annotation.Value; |
||||
import org.springframework.data.redis.core.RedisTemplate; |
||||
import org.springframework.stereotype.Service; |
||||
|
||||
import javax.sql.DataSource; |
||||
import java.io.BufferedReader; |
||||
import java.io.File; |
||||
import java.io.InputStreamReader; |
||||
import java.net.HttpURLConnection; |
||||
import java.net.InetSocketAddress; |
||||
import java.net.Socket; |
||||
import java.net.URL; |
||||
import java.nio.charset.Charset; |
||||
import java.util.ArrayList; |
||||
import java.util.LinkedHashMap; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
import java.util.concurrent.ConcurrentLinkedQueue; |
||||
|
||||
/** |
||||
* 系统监控服务实现 |
||||
* <p> |
||||
* 使用 JDK 内置 API 采集系统指标,通过 HttpURLConnection / DataSource / RedisTemplate |
||||
* 进行健康检查,通过 Socket 测量网络延迟,使用 ConcurrentLinkedQueue 维护趋势数据缓冲区。 |
||||
* </p> |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Slf4j |
||||
@Service |
||||
public class MonitorServiceImpl implements MonitorService { |
||||
|
||||
private static final int TREND_BUFFER_CAPACITY = 1000; |
||||
|
||||
private final DataSource dataSource; |
||||
private final RedisTemplate<String, Object> redisTemplate; |
||||
|
||||
public MonitorServiceImpl(DataSource dataSource, RedisTemplate<String, Object> redisTemplate) { |
||||
this.dataSource = dataSource; |
||||
this.redisTemplate = redisTemplate; |
||||
} |
||||
|
||||
/** |
||||
* 多个 Java 应用地址列表(逗号分隔),未配置时通过系统调用检测本地 Java 进程 |
||||
*/ |
||||
@Value("${monitor.health.java.urls:}") |
||||
private String javaHealthUrls; |
||||
|
||||
@Value("${monitor.network.target:localhost:83}") |
||||
private String networkTarget; |
||||
|
||||
/** |
||||
* 趋势数据缓冲区 — 线程安全,最多保留 TREND_BUFFER_CAPACITY 条记录。 |
||||
* 每条记录为 Map,包含字段: timestamp, cpu, memory, disk, network。 |
||||
*/ |
||||
private final ConcurrentLinkedQueue<Map<String, Object>> trendBuffer = new ConcurrentLinkedQueue<>(); |
||||
|
||||
// ==================== Public API ====================
|
||||
|
||||
@Override |
||||
public MonitorVO getSystemMetrics() { |
||||
MonitorVO vo = new MonitorVO(); |
||||
|
||||
// --- CPU ---
|
||||
vo.setCpuUsage(MonitorUtil.getCpuUsage()); |
||||
vo.setCpuCores(MonitorUtil.getAvailableProcessors()); |
||||
|
||||
// --- JVM Memory ---
|
||||
long jvmTotal = Runtime.getRuntime().totalMemory(); |
||||
long jvmFree = Runtime.getRuntime().freeMemory(); |
||||
long jvmMax = Runtime.getRuntime().maxMemory(); |
||||
vo.setMemoryTotal(jvmTotal); |
||||
vo.setMemoryFree(jvmFree); |
||||
vo.setMemoryUsed(jvmTotal - jvmFree); |
||||
vo.setMemoryUsage(jvmTotal > 0 ? (double) (jvmTotal - jvmFree) / jvmTotal * 100 : 0); |
||||
|
||||
// --- Physical Memory ---
|
||||
long physTotal = MonitorUtil.getTotalPhysicalMemory(); |
||||
long physFree = MonitorUtil.getFreePhysicalMemory(); |
||||
vo.setPhysicalMemoryTotal(physTotal); |
||||
vo.setPhysicalMemoryFree(physFree); |
||||
vo.setPhysicalMemoryUsage(physTotal > 0 ? (double) (physTotal - physFree) / physTotal * 100 : 0); |
||||
|
||||
// --- Disk ---
|
||||
List<DiskInfoVO> diskList = new ArrayList<>(); |
||||
for (File root : File.listRoots()) { |
||||
DiskInfoVO disk = new DiskInfoVO(); |
||||
disk.setPath(root.getAbsolutePath()); |
||||
long total = root.getTotalSpace(); |
||||
long free = root.getFreeSpace(); |
||||
long used = total - free; |
||||
disk.setTotalSpace(total); |
||||
disk.setFreeSpace(free); |
||||
disk.setUsedSpace(used); |
||||
disk.setUsagePercent(total > 0 ? (double) used / total * 100 : 0); |
||||
diskList.add(disk); |
||||
} |
||||
vo.setDiskList(diskList); |
||||
|
||||
// --- Network Latency ---
|
||||
vo.setNetworkLatencyMs(getNetworkLatency()); |
||||
|
||||
// --- Push to trend buffer ---
|
||||
pushTrendSnapshot(vo); |
||||
|
||||
return vo; |
||||
} |
||||
|
||||
@Override |
||||
public List<ServiceStatusVO> getServiceStatus() { |
||||
List<ServiceStatusVO> statusList = new ArrayList<>(); |
||||
|
||||
statusList.addAll(checkJavaServices()); |
||||
statusList.add(checkMysqlService()); |
||||
statusList.add(checkRedisService()); |
||||
|
||||
return statusList; |
||||
} |
||||
|
||||
@Override |
||||
public Long getNetworkLatency() { |
||||
String host = "localhost"; |
||||
int port = 8080; |
||||
|
||||
// Parse host:port from config
|
||||
String target = networkTarget.trim(); |
||||
int colonIdx = target.lastIndexOf(':'); |
||||
if (colonIdx > 0) { |
||||
host = target.substring(0, colonIdx); |
||||
try { |
||||
port = Integer.parseInt(target.substring(colonIdx + 1)); |
||||
} catch (NumberFormatException e) { |
||||
log.warn("Invalid port in network target '{}', using default 83", target); |
||||
port = 83; |
||||
} |
||||
} else { |
||||
host = target; |
||||
} |
||||
|
||||
long start = System.currentTimeMillis(); |
||||
try (Socket socket = new Socket()) { |
||||
socket.connect(new InetSocketAddress(host, port), 3000); |
||||
return System.currentTimeMillis() - start; |
||||
} catch (Exception e) { |
||||
log.debug("Network latency check failed for {}:{} - {}", host, port, e.getMessage()); |
||||
return -1L; |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public List<TrendPointVO> getTrend(String type, int points) { |
||||
List<TrendPointVO> result = new ArrayList<>(); |
||||
int count = 0; |
||||
|
||||
// Iterate from newest to oldest (reverse order)
|
||||
Object[] snapshotArray = trendBuffer.toArray(); |
||||
for (int i = snapshotArray.length - 1; i >= 0 && count < points; i--) { |
||||
@SuppressWarnings("unchecked") |
||||
Map<String, Object> snapshot = (Map<String, Object>) snapshotArray[i]; |
||||
TrendPointVO point = new TrendPointVO(); |
||||
point.setTimestamp((Long) snapshot.get("timestamp")); |
||||
|
||||
Object value = snapshot.get(type); |
||||
if (value instanceof Number) { |
||||
point.setValue(((Number) value).doubleValue()); |
||||
result.add(0, point); // prepend to maintain chronological order
|
||||
count++; |
||||
} |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
// ==================== Health Checks ====================
|
||||
|
||||
/** |
||||
* 检查 Java 应用健康状态 |
||||
* <p> |
||||
* 若配置了 monitor.health.java.urls(逗号分隔),对每个 URL 进行 HTTP 健康检查; |
||||
* 否则通过系统调用(jps / wmic)检测本地运行的 Java 进程。 |
||||
* </p> |
||||
*/ |
||||
private List<ServiceStatusVO> checkJavaServices() { |
||||
String[] urls = parseJavaHealthUrls(); |
||||
if (urls.length > 0) { |
||||
return checkRemoteJavaServices(urls); |
||||
} |
||||
return detectLocalJavaProcesses(); |
||||
} |
||||
|
||||
/** |
||||
* 对配置的 URL 列表进行 HTTP 健康检查 |
||||
*/ |
||||
private List<ServiceStatusVO> checkRemoteJavaServices(String[] urls) { |
||||
List<ServiceStatusVO> results = new ArrayList<>(); |
||||
for (String url : urls) { |
||||
String trimmed = url.trim(); |
||||
if (!trimmed.isEmpty()) { |
||||
results.add(checkSingleJavaService(trimmed, extractAppName(trimmed))); |
||||
} |
||||
} |
||||
return results; |
||||
} |
||||
|
||||
/** |
||||
* 通过系统调用检测本地 Java 进程 |
||||
* <p> |
||||
* 优先使用 JDK 自带的 jps -l 命令(显示主类名), |
||||
* 若不可用则降级为 wmic 命令。 |
||||
* </p> |
||||
*/ |
||||
private List<ServiceStatusVO> detectLocalJavaProcesses() { |
||||
List<ServiceStatusVO> results = new ArrayList<>(); |
||||
long start = System.currentTimeMillis(); |
||||
|
||||
// 优先 jps -l
|
||||
boolean jpsSuccess = tryJpsDetection(results, start); |
||||
|
||||
// jps 失败则尝试 wmic
|
||||
if (!jpsSuccess) { |
||||
tryWmicDetection(results, start); |
||||
} |
||||
|
||||
// 未检测到任何 Java 进程
|
||||
if (results.isEmpty()) { |
||||
ServiceStatusVO status = new ServiceStatusVO(); |
||||
status.setName("Java Application"); |
||||
status.setStatus("DOWN"); |
||||
status.setLatencyMs(System.currentTimeMillis() - start); |
||||
status.setErrorMessage("No local Java processes detected"); |
||||
results.add(status); |
||||
} |
||||
|
||||
return results; |
||||
} |
||||
|
||||
/** |
||||
* 使用 jps -l 检测本地 Java 进程 |
||||
* |
||||
* @return true 表示检测成功且有结果 |
||||
*/ |
||||
private boolean tryJpsDetection(List<ServiceStatusVO> results, long start) { |
||||
try { |
||||
Process process = Runtime.getRuntime().exec(new String[]{"jps", "-l"}); |
||||
try (BufferedReader reader = new BufferedReader( |
||||
new InputStreamReader(process.getInputStream(), Charset.forName("GBK")))) { |
||||
String line; |
||||
while ((line = reader.readLine()) != null) { |
||||
line = line.trim(); |
||||
if (line.isEmpty()) { |
||||
continue; |
||||
} |
||||
String[] parts = line.split("\\s+", 2); |
||||
ServiceStatusVO status = new ServiceStatusVO(); |
||||
status.setStatus("UP"); |
||||
status.setLatencyMs(System.currentTimeMillis() - start); |
||||
if (parts.length >= 2) { |
||||
String mainClass = parts[1]; |
||||
String shortName = mainClass.substring(mainClass.lastIndexOf('.') + 1); |
||||
status.setName("Java (" + shortName + ")"); |
||||
status.setErrorMessage("PID: " + parts[0] + ", " + mainClass); |
||||
} else { |
||||
status.setName("Java (PID:" + parts[0] + ")"); |
||||
status.setErrorMessage("PID: " + parts[0]); |
||||
} |
||||
results.add(status); |
||||
} |
||||
} |
||||
int exitCode = process.waitFor(); |
||||
return exitCode == 0 && !results.isEmpty(); |
||||
} catch (Exception e) { |
||||
log.debug("jps detection failed: {}", e.getMessage()); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 使用 wmic 检测本地 Java 进程(jps 不可用时的降级方案) |
||||
*/ |
||||
private void tryWmicDetection(List<ServiceStatusVO> results, long start) { |
||||
try { |
||||
Process process = Runtime.getRuntime().exec( |
||||
new String[]{"wmic", "process", "where", "name='java.exe'", "get", "ProcessId,CommandLine", "/FORMAT:CSV"}); |
||||
try (BufferedReader reader = new BufferedReader( |
||||
new InputStreamReader(process.getInputStream(), Charset.forName("GBK")))) { |
||||
String line; |
||||
boolean header = true; |
||||
while ((line = reader.readLine()) != null) { |
||||
line = line.trim(); |
||||
if (line.isEmpty()) { |
||||
continue; |
||||
} |
||||
if (header) { |
||||
header = false; |
||||
continue; |
||||
} |
||||
// CSV format: Node,CommandLine,ProcessId
|
||||
String[] csvParts = line.split(","); |
||||
if (csvParts.length >= 2) { |
||||
ServiceStatusVO status = new ServiceStatusVO(); |
||||
String cmdLine = csvParts[1].replace("\"", ""); |
||||
String appName = extractJavaAppName(cmdLine); |
||||
status.setName("Java (" + appName + ")"); |
||||
status.setStatus("UP"); |
||||
status.setLatencyMs(System.currentTimeMillis() - start); |
||||
String pid = csvParts.length >= 3 ? csvParts[2].replace("\"", "") : "?"; |
||||
status.setErrorMessage("PID: " + pid); |
||||
results.add(status); |
||||
} |
||||
} |
||||
} |
||||
} catch (Exception e) { |
||||
log.debug("wmic detection failed: {}", e.getMessage()); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 检查单个 Java 应用 HTTP 健康状态 |
||||
*/ |
||||
private ServiceStatusVO checkSingleJavaService(String url, String displayName) { |
||||
ServiceStatusVO status = new ServiceStatusVO(); |
||||
status.setName(displayName); |
||||
long start = System.currentTimeMillis(); |
||||
|
||||
try { |
||||
URL healthUrl = new URL(url); |
||||
HttpURLConnection conn = (HttpURLConnection) healthUrl.openConnection(); |
||||
conn.setConnectTimeout(3000); |
||||
conn.setReadTimeout(3000); |
||||
conn.setRequestMethod("GET"); |
||||
int responseCode = conn.getResponseCode(); |
||||
|
||||
status.setLatencyMs(System.currentTimeMillis() - start); |
||||
if (responseCode == 200) { |
||||
status.setStatus("UP"); |
||||
} else { |
||||
status.setStatus("DOWN"); |
||||
status.setErrorMessage("HTTP " + responseCode); |
||||
} |
||||
conn.disconnect(); |
||||
} catch (Exception e) { |
||||
status.setLatencyMs(System.currentTimeMillis() - start); |
||||
status.setStatus("DOWN"); |
||||
status.setErrorMessage(e.getMessage()); |
||||
log.debug("Java health check failed for {}: {}", url, e.getMessage()); |
||||
} |
||||
|
||||
return status; |
||||
} |
||||
|
||||
/** |
||||
* 解析逗号分隔的 Java 应用地址列表 |
||||
*/ |
||||
private String[] parseJavaHealthUrls() { |
||||
if (javaHealthUrls != null && !javaHealthUrls.trim().isEmpty()) { |
||||
return javaHealthUrls.split(","); |
||||
} |
||||
return new String[0]; |
||||
} |
||||
|
||||
/** |
||||
* 从 URL 中提取应用显示名称(取 host:port) |
||||
*/ |
||||
private String extractAppName(String url) { |
||||
try { |
||||
URL parsed = new URL(url); |
||||
return "Java Application (" + parsed.getHost() + ":" + parsed.getPort() + ")"; |
||||
} catch (Exception e) { |
||||
return "Java Application (" + url + ")"; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 从 wmic 命令行中提取 Java 应用名称 |
||||
*/ |
||||
private String extractJavaAppName(String cmdLine) { |
||||
if (cmdLine == null || cmdLine.isEmpty()) { |
||||
return "Unknown"; |
||||
} |
||||
// -jar 方式启动 → 提取 jar 文件名
|
||||
int jarIdx = cmdLine.indexOf("-jar"); |
||||
if (jarIdx >= 0) { |
||||
String afterJar = cmdLine.substring(jarIdx + 4).trim(); |
||||
String jarFile = afterJar.split("\\s+")[0]; |
||||
int lastSep = jarFile.replace('\\', '/').lastIndexOf('/'); |
||||
return lastSep >= 0 ? jarFile.substring(lastSep + 1) : jarFile; |
||||
} |
||||
// 主类方式启动 → 提取类名简称
|
||||
String[] parts = cmdLine.split("\\s+"); |
||||
for (int i = parts.length - 1; i >= 0; i--) { |
||||
String p = parts[i].trim(); |
||||
if (p.contains(".") && !p.startsWith("-") && !p.startsWith("-D") && !p.startsWith("-X")) { |
||||
return p.substring(p.lastIndexOf('.') + 1); |
||||
} |
||||
} |
||||
return "Java Process"; |
||||
} |
||||
|
||||
/** |
||||
* 检查 MySQL 数据库连接状态 |
||||
*/ |
||||
private ServiceStatusVO checkMysqlService() { |
||||
ServiceStatusVO status = new ServiceStatusVO(); |
||||
status.setName("MySQL"); |
||||
long start = System.currentTimeMillis(); |
||||
|
||||
try (java.sql.Connection conn = dataSource.getConnection()) { |
||||
boolean valid = conn.isValid(5); |
||||
status.setLatencyMs(System.currentTimeMillis() - start); |
||||
if (valid) { |
||||
status.setStatus("UP"); |
||||
} else { |
||||
status.setStatus("DOWN"); |
||||
status.setErrorMessage("Connection validation returned false"); |
||||
} |
||||
} catch (Exception e) { |
||||
status.setLatencyMs(System.currentTimeMillis() - start); |
||||
status.setStatus("DOWN"); |
||||
status.setErrorMessage(e.getMessage()); |
||||
log.debug("MySQL health check failed: {}", e.getMessage()); |
||||
} |
||||
|
||||
return status; |
||||
} |
||||
|
||||
/** |
||||
* 检查 Redis 连接状态 |
||||
*/ |
||||
private ServiceStatusVO checkRedisService() { |
||||
ServiceStatusVO status = new ServiceStatusVO(); |
||||
status.setName("Redis"); |
||||
long start = System.currentTimeMillis(); |
||||
|
||||
try { |
||||
String pingResult = redisTemplate.getConnectionFactory().getConnection().ping(); |
||||
status.setLatencyMs(System.currentTimeMillis() - start); |
||||
if ("PONG".equalsIgnoreCase(pingResult)) { |
||||
status.setStatus("UP"); |
||||
} else { |
||||
status.setStatus("DOWN"); |
||||
status.setErrorMessage("Unexpected ping response: " + pingResult); |
||||
} |
||||
} catch (Exception e) { |
||||
status.setLatencyMs(System.currentTimeMillis() - start); |
||||
status.setStatus("DOWN"); |
||||
status.setErrorMessage(e.getMessage()); |
||||
log.debug("Redis health check failed: {}", e.getMessage()); |
||||
} |
||||
|
||||
return status; |
||||
} |
||||
|
||||
// ==================== Trend Buffer ====================
|
||||
|
||||
/** |
||||
* 将当前快照推入趋势缓冲区,超过容量时移除最旧记录 |
||||
*/ |
||||
private void pushTrendSnapshot(MonitorVO vo) { |
||||
Map<String, Object> snapshot = new LinkedHashMap<>(); |
||||
snapshot.put("timestamp", System.currentTimeMillis()); |
||||
snapshot.put("cpu", vo.getCpuUsage()); |
||||
snapshot.put("memory", vo.getMemoryUsage()); |
||||
// 磁盘使用率取第一个磁盘的使用率作为整体指标
|
||||
double diskUsage = 0; |
||||
if (vo.getDiskList() != null && !vo.getDiskList().isEmpty()) { |
||||
diskUsage = vo.getDiskList().get(0).getUsagePercent(); |
||||
} |
||||
snapshot.put("disk", diskUsage); |
||||
snapshot.put("network", (double) vo.getNetworkLatencyMs()); |
||||
|
||||
trendBuffer.offer(snapshot); |
||||
|
||||
// 超过容量时移除最旧记录
|
||||
while (trendBuffer.size() > TREND_BUFFER_CAPACITY) { |
||||
trendBuffer.poll(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,264 @@ |
||||
package org.springblade.monitor.service.impl; |
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
import lombok.AllArgsConstructor; |
||||
import lombok.extern.slf4j.Slf4j; |
||||
import org.springblade.core.tool.api.R; |
||||
import org.springblade.lims.entry.Examine; |
||||
import org.springblade.lims.mapper.ExamineMapper; |
||||
import org.springblade.monitor.service.PersonnelWorkloadService; |
||||
import org.springblade.monitor.vo.WorkloadDetailVO; |
||||
import org.springblade.monitor.vo.WorkloadSummaryVO; |
||||
import org.springblade.system.entity.Dept; |
||||
import org.springblade.system.feign.ISysClient; |
||||
import org.springblade.system.user.entity.User; |
||||
import org.springblade.system.user.feign.IUserClient; |
||||
import org.springframework.stereotype.Service; |
||||
|
||||
import java.math.BigDecimal; |
||||
import java.math.RoundingMode; |
||||
import java.util.ArrayList; |
||||
import java.util.Date; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
import java.util.stream.Collectors; |
||||
|
||||
/** |
||||
* 人员工作量统计服务实现 |
||||
* <p> |
||||
* 基于 Examine 检验记录,按人员维度统计工作量。 |
||||
* 跨数据源通过 Feign 客户端查询 blade_user / blade_dept。 |
||||
* </p> |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Slf4j |
||||
@Service |
||||
@AllArgsConstructor |
||||
public class PersonnelWorkloadServiceImpl implements PersonnelWorkloadService { |
||||
|
||||
private final ExamineMapper examineMapper; |
||||
private final IUserClient userClient; |
||||
private final ISysClient sysClient; |
||||
|
||||
@Override |
||||
public WorkloadSummaryVO getWorkload(Date startDate, Date endDate, Long deptId) { |
||||
// 1. 查询 Examine 记录
|
||||
List<Examine> examineList = queryExamineRecords(startDate, endDate, deptId); |
||||
|
||||
// 2. 按 examineBy 分组
|
||||
Map<String, List<Examine>> groupByUser = examineList.stream() |
||||
.filter(e -> e.getExamineBy() != null && !e.getExamineBy().isEmpty()) |
||||
.collect(Collectors.groupingBy(Examine::getExamineBy)); |
||||
|
||||
// 3. 构建明细
|
||||
List<WorkloadDetailVO> details = new ArrayList<>(); |
||||
int totalDetectionCount = 0; |
||||
BigDecimal sumDelayRate = BigDecimal.ZERO; |
||||
|
||||
for (Map.Entry<String, List<Examine>> entry : groupByUser.entrySet()) { |
||||
WorkloadDetailVO detail = buildDetail(entry.getKey(), entry.getValue()); |
||||
details.add(detail); |
||||
totalDetectionCount += detail.getDetectionCount(); |
||||
sumDelayRate = sumDelayRate.add(detail.getDelayRate()); |
||||
} |
||||
|
||||
// 4. 构建汇总
|
||||
return buildSummary(details, totalDetectionCount, sumDelayRate, startDate, endDate, examineList); |
||||
} |
||||
|
||||
/** |
||||
* 查询指定时间范围内的 Examine 记录 |
||||
*/ |
||||
private List<Examine> queryExamineRecords(Date startDate, Date endDate, Long deptId) { |
||||
LambdaQueryWrapper<Examine> wrapper = new LambdaQueryWrapper<>(); |
||||
|
||||
if (startDate != null) { |
||||
wrapper.ge(Examine::getReceiveTime, startDate); |
||||
} |
||||
if (endDate != null) { |
||||
wrapper.le(Examine::getReceiveTime, endDate); |
||||
} |
||||
if (deptId != null) { |
||||
wrapper.eq(Examine::getDeptId, deptId); |
||||
} |
||||
// 排除未分配人员的记录
|
||||
wrapper.isNotNull(Examine::getExamineBy); |
||||
wrapper.ne(Examine::getExamineBy, ""); |
||||
|
||||
return examineMapper.selectList(wrapper); |
||||
} |
||||
|
||||
/** |
||||
* 构建单个人员的工作量明细 |
||||
*/ |
||||
private WorkloadDetailVO buildDetail(String userId, List<Examine> records) { |
||||
Date now = new Date(); |
||||
|
||||
int detectionCount = records.size(); |
||||
int completedCount = (int) records.stream() |
||||
.filter(e -> "1".equals(e.getIsFinished())) |
||||
.count(); |
||||
int delayCount = (int) records.stream() |
||||
.filter(e -> !"1".equals(e.getIsFinished()) |
||||
&& e.getDemandCompletionTime() != null |
||||
&& e.getDemandCompletionTime().before(now)) |
||||
.count(); |
||||
|
||||
// 延期率 = delayCount / detectionCount * 100
|
||||
BigDecimal delayRate = detectionCount > 0 |
||||
? BigDecimal.valueOf(delayCount) |
||||
.multiply(BigDecimal.valueOf(100)) |
||||
.divide(BigDecimal.valueOf(detectionCount), 2, RoundingMode.HALF_UP) |
||||
: BigDecimal.ZERO; |
||||
|
||||
// 平均耗时
|
||||
String avgTime = calculateAvgTime(records); |
||||
|
||||
// 用户真实姓名
|
||||
String realName = resolveUserName(userId); |
||||
|
||||
// 部门名称(取第一条记录的 deptId)
|
||||
String deptName = null; |
||||
if (!records.isEmpty() && records.get(0).getDeptId() != null) { |
||||
deptName = resolveDeptName(records.get(0).getDeptId()); |
||||
} |
||||
|
||||
WorkloadDetailVO detail = new WorkloadDetailVO(); |
||||
detail.setUserId(userId); |
||||
detail.setRealName(realName); |
||||
detail.setDeptName(deptName); |
||||
detail.setDetectionCount(detectionCount); |
||||
detail.setCompletedCount(completedCount); |
||||
detail.setDelayCount(delayCount); |
||||
detail.setDelayRate(delayRate); |
||||
detail.setAvgTime(avgTime); |
||||
|
||||
return detail; |
||||
} |
||||
|
||||
/** |
||||
* 构建汇总信息 |
||||
*/ |
||||
private WorkloadSummaryVO buildSummary(List<WorkloadDetailVO> details, |
||||
int totalDetectionCount, |
||||
BigDecimal sumDelayRate, |
||||
Date startDate, |
||||
Date endDate, |
||||
List<Examine> allRecords) { |
||||
WorkloadSummaryVO summary = new WorkloadSummaryVO(); |
||||
summary.setTotalDetectionCount(totalDetectionCount); |
||||
summary.setDetails(details); |
||||
|
||||
// 平均延期率 = 所有人员延期率之和 / 人数
|
||||
int personCount = details.size(); |
||||
BigDecimal avgDelayRate = personCount > 0 |
||||
? sumDelayRate.divide(BigDecimal.valueOf(personCount), 2, RoundingMode.HALF_UP) |
||||
: BigDecimal.ZERO; |
||||
summary.setAvgDelayRate(avgDelayRate); |
||||
|
||||
// 日均每人检测数 = total / 天数 / 人数
|
||||
if (startDate != null && endDate != null && personCount > 0) { |
||||
long diffMs = endDate.getTime() - startDate.getTime(); |
||||
long days = diffMs / (1000 * 60 * 60 * 24); |
||||
if (days <= 0) { |
||||
days = 1; // 最小为 1 天
|
||||
} |
||||
BigDecimal avgDaily = BigDecimal.valueOf(totalDetectionCount) |
||||
.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP) |
||||
.divide(BigDecimal.valueOf(personCount), 2, RoundingMode.HALF_UP); |
||||
summary.setAvgDailyPerPerson(avgDaily); |
||||
} else { |
||||
summary.setAvgDailyPerPerson(BigDecimal.ZERO); |
||||
} |
||||
|
||||
// 整体平均耗时
|
||||
summary.setAvgTime(calculateOverallAvgTime(allRecords)); |
||||
|
||||
return summary; |
||||
} |
||||
|
||||
/** |
||||
* 计算已完成的平均耗时 |
||||
*/ |
||||
private String calculateAvgTime(List<Examine> records) { |
||||
long totalMillis = 0; |
||||
int count = 0; |
||||
for (Examine e : records) { |
||||
if ("1".equals(e.getIsFinished()) |
||||
&& e.getFinishTime() != null |
||||
&& e.getReceiveTime() != null) { |
||||
totalMillis += e.getFinishTime().getTime() - e.getReceiveTime().getTime(); |
||||
count++; |
||||
} |
||||
} |
||||
return formatAvgTime(totalMillis, count); |
||||
} |
||||
|
||||
/** |
||||
* 计算整体平均耗时 |
||||
*/ |
||||
private String calculateOverallAvgTime(List<Examine> allRecords) { |
||||
long totalMillis = 0; |
||||
int count = 0; |
||||
for (Examine e : allRecords) { |
||||
if ("1".equals(e.getIsFinished()) |
||||
&& e.getFinishTime() != null |
||||
&& e.getReceiveTime() != null) { |
||||
totalMillis += e.getFinishTime().getTime() - e.getReceiveTime().getTime(); |
||||
count++; |
||||
} |
||||
} |
||||
return formatAvgTime(totalMillis, count); |
||||
} |
||||
|
||||
/** |
||||
* 格式化平均耗时 |
||||
*/ |
||||
private String formatAvgTime(long totalMillis, int count) { |
||||
if (count == 0) { |
||||
return "-"; |
||||
} |
||||
long avgMillis = totalMillis / count; |
||||
long hours = avgMillis / (1000 * 60 * 60); |
||||
long minutes = (avgMillis % (1000 * 60 * 60)) / (1000 * 60); |
||||
long seconds = (avgMillis % (1000 * 60)) / 1000; |
||||
return String.format("%d时%d分%d秒", hours, minutes, seconds); |
||||
} |
||||
|
||||
/** |
||||
* 通过 Feign 查询 blade_user 获取用户真实姓名 |
||||
*/ |
||||
private String resolveUserName(String userId) { |
||||
try { |
||||
R<User> userR = userClient.userInfoById(Long.valueOf(userId)); |
||||
if (userR != null && userR.getData() != null) { |
||||
String realName = userR.getData().getRealName(); |
||||
if (realName != null && !realName.isEmpty()) { |
||||
return realName; |
||||
} |
||||
// 退回到 name
|
||||
return userR.getData().getName(); |
||||
} |
||||
} catch (Exception e) { |
||||
log.warn("Failed to resolve user name for userId: {}", userId, e); |
||||
} |
||||
return userId; |
||||
} |
||||
|
||||
/** |
||||
* 通过 Feign 查询 blade_dept 获取部门名称 |
||||
*/ |
||||
private String resolveDeptName(Long deptId) { |
||||
try { |
||||
R<Dept> deptR = sysClient.getDept(deptId); |
||||
if (deptR != null && deptR.getData() != null) { |
||||
return deptR.getData().getDeptName(); |
||||
} |
||||
} catch (Exception e) { |
||||
log.warn("Failed to resolve dept name for deptId: {}", deptId, e); |
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
@ -0,0 +1,132 @@ |
||||
package org.springblade.monitor.util; |
||||
|
||||
import java.lang.management.ManagementFactory; |
||||
import java.lang.management.OperatingSystemMXBean; |
||||
import java.lang.reflect.Method; |
||||
|
||||
/** |
||||
* 系统监控工具类 |
||||
* <p> |
||||
* 封装 JDK 内置 MXBean 访问,提供 CPU、物理内存等系统级指标的读取方法。 |
||||
* 优先使用 {@link com.sun.management.OperatingSystemMXBean} 获取更精确的物理内存数据, |
||||
* 若不可用则降级为 {@link java.lang.management.OperatingSystemMXBean} 的基本信息。 |
||||
* </p> |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
public class MonitorUtil { |
||||
|
||||
private MonitorUtil() { |
||||
// 工具类,禁止实例化
|
||||
} |
||||
|
||||
/** |
||||
* 获取系统 CPU 负载平均值(过去 1 分钟) |
||||
* |
||||
* @return CPU 负载,-1 表示不可用 |
||||
*/ |
||||
public static double getSystemLoadAverage() { |
||||
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); |
||||
return osBean.getSystemLoadAverage(); |
||||
} |
||||
|
||||
/** |
||||
* 获取可用 CPU 核心数 |
||||
* |
||||
* @return 核心数 |
||||
*/ |
||||
public static int getAvailableProcessors() { |
||||
return Runtime.getRuntime().availableProcessors(); |
||||
} |
||||
|
||||
/** |
||||
* 获取系统 CPU 使用率(百分比 0~100) |
||||
* <p> |
||||
* 优先使用系统负载平均值(Linux/Unix), |
||||
* 不可用时(Windows 上 getSystemLoadAverage 始终返回 -1) |
||||
* 降级为 {@link com.sun.management.OperatingSystemMXBean#getSystemCpuLoad()}, |
||||
* 该方法返回 0.0~1.0 的 CPU 使用率,乘以 100 转换为百分比。 |
||||
* </p> |
||||
* |
||||
* @return CPU 使用率 (0~100),-1 表示不可获取 |
||||
*/ |
||||
public static double getCpuUsage() { |
||||
double loadAvg = getSystemLoadAverage(); |
||||
if (loadAvg >= 0) { |
||||
return loadAvg; |
||||
} |
||||
// Windows 降级:com.sun.management.OperatingSystemMXBean.getSystemCpuLoad()
|
||||
try { |
||||
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); |
||||
Method method = osBean.getClass().getMethod("getSystemCpuLoad"); |
||||
method.setAccessible(true); |
||||
Object result = method.invoke(osBean); |
||||
if (result instanceof Double) { |
||||
double cpuLoad = (Double) result; |
||||
if (cpuLoad >= 0) { |
||||
return cpuLoad * 100; |
||||
} |
||||
} |
||||
} catch (Exception e) { |
||||
// ignore
|
||||
} |
||||
return -1; |
||||
} |
||||
|
||||
/** |
||||
* 获取 JVM 内存使用率 (%) |
||||
* |
||||
* @return 使用率 (0~100),总内存为 0 时返回 0 |
||||
*/ |
||||
public static double getJvmMemoryUsage() { |
||||
long total = Runtime.getRuntime().totalMemory(); |
||||
long free = Runtime.getRuntime().freeMemory(); |
||||
if (total == 0) { |
||||
return 0; |
||||
} |
||||
return (double) (total - free) / total * 100; |
||||
} |
||||
|
||||
/** |
||||
* 获取物理内存总大小 (bytes) |
||||
* <p> |
||||
* 通过反射调用 {@link com.sun.management.OperatingSystemMXBean#getTotalPhysicalMemorySize()}, |
||||
* 若该接口不可用则返回 -1。 |
||||
* </p> |
||||
* |
||||
* @return 物理内存总大小 (bytes),-1 表示不可获取 |
||||
*/ |
||||
public static long getTotalPhysicalMemory() { |
||||
try { |
||||
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); |
||||
Method method = osBean.getClass().getMethod("getTotalPhysicalMemorySize"); |
||||
method.setAccessible(true); |
||||
Object result = method.invoke(osBean); |
||||
return result instanceof Long ? (Long) result : -1; |
||||
} catch (Exception e) { |
||||
return -1; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 获取空闲物理内存大小 (bytes) |
||||
* <p> |
||||
* 通过反射调用 {@link com.sun.management.OperatingSystemMXBean#getFreePhysicalMemorySize()}, |
||||
* 若该接口不可用则返回 -1。 |
||||
* </p> |
||||
* |
||||
* @return 空闲物理内存大小 (bytes),-1 表示不可获取 |
||||
*/ |
||||
public static long getFreePhysicalMemory() { |
||||
try { |
||||
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); |
||||
Method method = osBean.getClass().getMethod("getFreePhysicalMemorySize"); |
||||
method.setAccessible(true); |
||||
Object result = method.invoke(osBean); |
||||
return result instanceof Long ? (Long) result : -1; |
||||
} catch (Exception e) { |
||||
return -1; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,27 @@ |
||||
package org.springblade.monitor.vo; |
||||
|
||||
import lombok.Data; |
||||
|
||||
import java.io.Serializable; |
||||
|
||||
/** |
||||
* 磁盘信息 VO |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Data |
||||
public class DiskInfoVO implements Serializable { |
||||
private static final long serialVersionUID = 1L; |
||||
|
||||
/** 磁盘路径 */ |
||||
private String path; |
||||
/** 总空间 (bytes) */ |
||||
private long totalSpace; |
||||
/** 可用空间 (bytes) */ |
||||
private long freeSpace; |
||||
/** 已用空间 (bytes) */ |
||||
private long usedSpace; |
||||
/** 使用率 (%) */ |
||||
private double usagePercent; |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
package org.springblade.monitor.vo; |
||||
|
||||
import lombok.Data; |
||||
|
||||
import java.io.Serializable; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 系统监控指标 VO |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Data |
||||
public class MonitorVO implements Serializable { |
||||
private static final long serialVersionUID = 1L; |
||||
|
||||
/** CPU 负载 (SystemLoadAverage, -1 表示不可用) */ |
||||
private double cpuUsage; |
||||
/** CPU 核心数 */ |
||||
private int cpuCores; |
||||
|
||||
/** JVM 总内存 (bytes) */ |
||||
private long memoryTotal; |
||||
/** JVM 已用内存 (bytes) */ |
||||
private long memoryUsed; |
||||
/** JVM 空闲内存 (bytes) */ |
||||
private long memoryFree; |
||||
/** JVM 内存使用率 (%) */ |
||||
private double memoryUsage; |
||||
|
||||
/** 物理内存总大小 (bytes) */ |
||||
private long physicalMemoryTotal; |
||||
/** 物理内存可用大小 (bytes) */ |
||||
private long physicalMemoryFree; |
||||
/** 物理内存使用率 (%) */ |
||||
private double physicalMemoryUsage; |
||||
|
||||
/** 磁盘列表 */ |
||||
private List<DiskInfoVO> diskList; |
||||
|
||||
/** 网络延迟 (ms) */ |
||||
private long networkLatencyMs; |
||||
} |
||||
@ -0,0 +1,25 @@ |
||||
package org.springblade.monitor.vo; |
||||
|
||||
import lombok.Data; |
||||
|
||||
import java.io.Serializable; |
||||
|
||||
/** |
||||
* 服务健康状态 VO |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Data |
||||
public class ServiceStatusVO implements Serializable { |
||||
private static final long serialVersionUID = 1L; |
||||
|
||||
/** 服务名称 */ |
||||
private String name; |
||||
/** 状态: UP / DOWN */ |
||||
private String status; |
||||
/** 响应延迟 (ms) */ |
||||
private long latencyMs; |
||||
/** 错误信息 (状态为 DOWN 时提供) */ |
||||
private String errorMessage; |
||||
} |
||||
@ -0,0 +1,21 @@ |
||||
package org.springblade.monitor.vo; |
||||
|
||||
import lombok.Data; |
||||
|
||||
import java.io.Serializable; |
||||
|
||||
/** |
||||
* 趋势数据点 VO |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Data |
||||
public class TrendPointVO implements Serializable { |
||||
private static final long serialVersionUID = 1L; |
||||
|
||||
/** 时间戳 (ms) */ |
||||
private long timestamp; |
||||
/** 指标值 */ |
||||
private double value; |
||||
} |
||||
@ -0,0 +1,41 @@ |
||||
package org.springblade.monitor.vo; |
||||
|
||||
import lombok.Data; |
||||
|
||||
import java.io.Serializable; |
||||
import java.math.BigDecimal; |
||||
|
||||
/** |
||||
* 人员工作量明细 VO |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Data |
||||
public class WorkloadDetailVO implements Serializable { |
||||
private static final long serialVersionUID = 1L; |
||||
|
||||
/** 用户ID (examineBy) */ |
||||
private String userId; |
||||
|
||||
/** 用户真实姓名 */ |
||||
private String realName; |
||||
|
||||
/** 部门名称 */ |
||||
private String deptName; |
||||
|
||||
/** 任务总数 */ |
||||
private Integer detectionCount; |
||||
|
||||
/** 已完成数 */ |
||||
private Integer completedCount; |
||||
|
||||
/** 延期数 */ |
||||
private Integer delayCount; |
||||
|
||||
/** 延期率 (%) */ |
||||
private BigDecimal delayRate; |
||||
|
||||
/** 平均完成耗时 */ |
||||
private String avgTime; |
||||
} |
||||
@ -0,0 +1,33 @@ |
||||
package org.springblade.monitor.vo; |
||||
|
||||
import lombok.Data; |
||||
|
||||
import java.io.Serializable; |
||||
import java.math.BigDecimal; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 人员工作量汇总 VO |
||||
* |
||||
* @author blade |
||||
* @since 2026-06-01 |
||||
*/ |
||||
@Data |
||||
public class WorkloadSummaryVO implements Serializable { |
||||
private static final long serialVersionUID = 1L; |
||||
|
||||
/** 总检测数 */ |
||||
private Integer totalDetectionCount; |
||||
|
||||
/** 平均延期率 (%) */ |
||||
private BigDecimal avgDelayRate; |
||||
|
||||
/** 日均每人检测数 */ |
||||
private BigDecimal avgDailyPerPerson; |
||||
|
||||
/** 平均完成耗时 */ |
||||
private String avgTime; |
||||
|
||||
/** 人员工作量明细列表 */ |
||||
private List<WorkloadDetailVO> details; |
||||
} |
||||
Loading…
Reference in new issue