用 flw_callback_reg / flw_callback_log 两张已有表拼装「这个应用接得好不好」,零新增表: - FlwCallbackLogRepository 增 3 个只读聚合:countByStatusSinceForRegs / countByStatusForRegs / countHungForRegs(按注册集合过滤,挂起口径与管理端「挂起」列表一致:status=0 且 retryCount >= hangAlertRetries) - 新增 AppHealthVo(model 包):appKey/appName/score/level/summary/checks(List<CheckItem>),检查项用 List 承载便于未来增删 - FlowCallbackAdminService.health(schId, appNames) 按 appKey 归组(同一应用多个 flowGuid 注册合并体检),六项检查加权算分:登记地址为私网/开发机 -40、base_path 缺失 -10、近 7 日成功率 <99% 或 =0% -15/-30、死信 -20、挂起 -12、注册停用 -8、无推送记录 -5;level 取最差项,问题应用 bad>warn>ok 置顶、同级按分升序 - FlowAdminController 增 GET /api/flow/admin/callback/health;应用名从 appAdminService.list(schId) 补齐,缺失回落 appKey 健康度面板与管理端三个列表(注册/日志/挂起死信)共用同一口径,避免两处数字打架。mvn -o package 通过。
This commit is contained in:
@@ -364,6 +364,23 @@ public class FlowAdminController {
|
||||
callbackAdminService.rePush(adminGuard.requireAdmin(user, null), logIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对接健康度体检(2026-10-15 议题二 P2):每应用一行六项检查 + 综合评分。
|
||||
* 数据全部来自已有资产(flw_callback_reg / flw_callback_log),零新增表;
|
||||
* 应用名从 flw_app 白名单补齐(缺失回落 appKey)。
|
||||
*/
|
||||
@GetMapping("/callback/health")
|
||||
public List<com.yunhai.flowcenter.model.AppHealthVo> callbackHealth(BoaUser user) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
java.util.Map<String, String> appNames = new java.util.HashMap<>();
|
||||
for (com.yunhai.flowcenter.entity.FlwApp app : appAdminService.list(schId)) {
|
||||
if (app.getAppKey() != null) {
|
||||
appNames.put(app.getAppKey(), app.getAppName());
|
||||
}
|
||||
}
|
||||
return callbackAdminService.health(schId, appNames);
|
||||
}
|
||||
|
||||
// ==================== 条件路由注册 / 重跑路由 ====================
|
||||
|
||||
/** 判断服务注册列表(flw_router 全局表;编辑器 routerKey 下拉与管理页共用) */
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.yunhai.flowcenter.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 对接健康度体检结果(2026-10-15 议题二 P2):
|
||||
* 每应用一行六项检查,数据全部来自已有资产(flw_callback_reg / flw_callback_log / probe),零新增表。
|
||||
*
|
||||
* <p>检查项固定六条(顺序即前端展示顺序),但用 List 承载以便未来增删:
|
||||
* <ol>
|
||||
* <li>登记地址形态(是否网关形态,非私网/开发机)</li>
|
||||
* <li>base_path 与应用 ctx 一致性</li>
|
||||
* <li>最近探测结果(最近的 flow.ping 日志)</li>
|
||||
* <li>近 7 日推送成功率</li>
|
||||
* <li>挂起 / 死信数</li>
|
||||
* <li>有 flowGuid 无登记行(漏注册)</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Data
|
||||
public class AppHealthVo {
|
||||
|
||||
/** 应用标识 */
|
||||
private String appKey;
|
||||
|
||||
private String appName;
|
||||
|
||||
/** 综合评分 0~100(六项加权;无数据项中性处理,不扣分) */
|
||||
private int score;
|
||||
|
||||
/** 总体结论:ok / warn / bad */
|
||||
private String level;
|
||||
|
||||
/** 一句话结论(前端顶部提示条用) */
|
||||
private String summary;
|
||||
|
||||
private List<CheckItem> checks;
|
||||
|
||||
/** 单条检查项 */
|
||||
@Data
|
||||
public static class CheckItem {
|
||||
|
||||
/** 检查项名称 */
|
||||
private String name;
|
||||
|
||||
/** ok / warn / bad */
|
||||
private String level;
|
||||
|
||||
/** 实测值展示文本(等宽字体渲染) */
|
||||
private String value;
|
||||
|
||||
/** 可选:修复建议(level != ok 时给出) */
|
||||
private String advice;
|
||||
|
||||
public CheckItem(String name, String level, String value) {
|
||||
this.name = name;
|
||||
this.level = level;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public CheckItem(String name, String level, String value, String advice) {
|
||||
this.name = name;
|
||||
this.level = level;
|
||||
this.value = value;
|
||||
this.advice = advice;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,53 @@ public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog,
|
||||
Page<FlwCallbackLog> pageHung(@Param("schId") Long schId, @Param("minRetry") int minRetry, Pageable pageable);
|
||||
|
||||
List<FlwCallbackLog> findByIdInAndSchId(Collection<Long> ids, Long schId);
|
||||
|
||||
// ==================== 健康度体检聚合(2026-10-15) ====================
|
||||
// 全部为只读统计,不新增表;口径见 FlowCallbackAdminService#health。
|
||||
|
||||
/** 近 7 日推送成败计数:按 状态(FULLY_QUALIFIED) 返回 [status, count] */
|
||||
@Query("select l.status, count(l) from FlwCallbackLog l "
|
||||
+ "where l.schId = :schId and l.pushTime >= :since "
|
||||
+ "group by l.status")
|
||||
List<Object[]> countByStatusSince(@Param("schId") Long schId, @Param("since") LocalDateTime since);
|
||||
|
||||
/** 死信数(status=FULLY_QUALIFIED 由调用方传入) */
|
||||
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.status = :status")
|
||||
long countByStatus(@Param("schId") Long schId, @Param("status") Integer status);
|
||||
|
||||
/**
|
||||
* 挂起数:待推且重试次数达阈值。
|
||||
* 与 pageHung 同口径(管理端"挂起"列表的计数版)。
|
||||
*/
|
||||
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.status = 0 "
|
||||
+ "and l.retryCount >= :minRetry")
|
||||
long countHung(@Param("schId") Long schId, @Param("minRetry") int minRetry);
|
||||
|
||||
/** 某应用的最近一次推送时间(判活) */
|
||||
@Query("select max(l.pushTime) from FlwCallbackLog l where l.schId = :schId and l.status = 1")
|
||||
LocalDateTime lastSuccessTime(@Param("schId") Long schId);
|
||||
|
||||
// -------- 按应用 appKey 维度过滤(健康度面板每应用一行,用 regId 归属关联) --------
|
||||
|
||||
/** 某注册集合近 7 日成败计数 */
|
||||
@Query("select l.status, count(l) from FlwCallbackLog l "
|
||||
+ "where l.schId = :schId and l.regId in :regIds and l.pushTime >= :since "
|
||||
+ "group by l.status")
|
||||
List<Object[]> countByStatusSinceForRegs(@Param("schId") Long schId,
|
||||
@Param("regIds") Collection<Long> regIds,
|
||||
@Param("since") LocalDateTime since);
|
||||
|
||||
/** 某注册集合的死信数 */
|
||||
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId "
|
||||
+ "and l.regId in :regIds and l.status = :status")
|
||||
long countByStatusForRegs(@Param("schId") Long schId,
|
||||
@Param("regIds") Collection<Long> regIds,
|
||||
@Param("status") Integer status);
|
||||
|
||||
/** 某注册集合的挂起数 */
|
||||
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.regId in :regIds "
|
||||
+ "and l.status = 0 and l.retryCount >= :minRetry")
|
||||
long countHungForRegs(@Param("schId") Long schId,
|
||||
@Param("regIds") Collection<Long> regIds,
|
||||
@Param("minRetry") int minRetry);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.yunhai.flowcenter.callback.CallbackPushService;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.AppHealthVo;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
@@ -144,6 +145,206 @@ public class FlowCallbackAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 对接健康度体检(2026-10-15 议题二 P2) ====================
|
||||
|
||||
/** 私网/开发机地址特征(登记地址应为网关形态,见《子应用接入流程中心规范》§4.2) */
|
||||
private static final java.util.regex.Pattern PRIVATE_HOST = java.util.regex.Pattern.compile(
|
||||
"://(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|"
|
||||
+ "10\\.[0-9.]+|192\\.168\\.[0-9.]+|172\\.(1[6-9]|2[0-9]|3[01])\\.[0-9.]+)"
|
||||
+ "(:[0-9]+)?");
|
||||
|
||||
/** 挂起判定阈值(与管理端「挂起列表」同口径) */
|
||||
private static final int HUNG_RETRY_MIN = 3;
|
||||
|
||||
/**
|
||||
* 全量应用健康度体检(每应用一行)。
|
||||
*
|
||||
* <p>数据全部来自已有表:{@code flw_callback_reg}(登记地址/base_path)、
|
||||
* {@code flw_callback_log}(推送成败/死信/挂起)。不新增任何表。
|
||||
*
|
||||
* <p>应用清单来自注册表 distinct appKey(未接入回调的应用不参与体检——
|
||||
* 无注册行本身即"未接入",由向导负责,不是健康度问题)。
|
||||
*
|
||||
* @param appNames appKey → 应用名(来自 flw_app;缺失时回落 appKey)
|
||||
*/
|
||||
public List<AppHealthVo> health(Long schId, java.util.Map<String, String> appNames) {
|
||||
List<FlwCallbackReg> allRegs = regRepository.findBySchIdOrderByUpdateTimeDesc(schId);
|
||||
java.time.LocalDateTime since = java.time.LocalDateTime.now().minusDays(7);
|
||||
|
||||
// 按 appKey 归组(同一应用的多个 flowGuid 注册合并体检)
|
||||
java.util.Map<String, List<FlwCallbackReg>> byApp = new java.util.LinkedHashMap<>();
|
||||
for (FlwCallbackReg reg : allRegs) {
|
||||
if (reg.getAppKey() == null || reg.getAppKey().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
byApp.computeIfAbsent(reg.getAppKey(), k -> new java.util.ArrayList<>()).add(reg);
|
||||
}
|
||||
|
||||
List<AppHealthVo> out = new java.util.ArrayList<>();
|
||||
for (java.util.Map.Entry<String, List<FlwCallbackReg>> e : byApp.entrySet()) {
|
||||
out.add(buildAppHealth(schId, e.getKey(),
|
||||
appNames == null ? null : appNames.get(e.getKey()), e.getValue(), since));
|
||||
}
|
||||
// 问题应用优先(bad → warn → ok),同级按评分升序
|
||||
out.sort(java.util.Comparator
|
||||
.comparingInt((AppHealthVo v) -> levelRank(v.getLevel()))
|
||||
.thenComparingInt(AppHealthVo::getScore));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int levelRank(String level) {
|
||||
if ("bad".equals(level)) {
|
||||
return 0;
|
||||
}
|
||||
if ("warn".equals(level)) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
/** 单应用体检:六项检查逐项判定并加权算分 */
|
||||
private AppHealthVo buildAppHealth(Long schId, String appKey, String appName,
|
||||
List<FlwCallbackReg> regs, java.time.LocalDateTime since) {
|
||||
List<Long> regIds = new java.util.ArrayList<>();
|
||||
for (FlwCallbackReg r : regs) {
|
||||
regIds.add(r.getId());
|
||||
}
|
||||
|
||||
List<AppHealthVo.CheckItem> checks = new java.util.ArrayList<>();
|
||||
int score = 100;
|
||||
|
||||
// ---- 1) 登记地址形态:任何一条是私网/开发机即 bad ----
|
||||
String badUrl = null;
|
||||
for (FlwCallbackReg r : regs) {
|
||||
if (r.getCallbackUrl() != null && PRIVATE_HOST.matcher(r.getCallbackUrl()).find()) {
|
||||
badUrl = r.getCallbackUrl();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (badUrl != null) {
|
||||
checks.add(new AppHealthVo.CheckItem("登记地址为网关形态", "bad", badUrl,
|
||||
"删除本地镜像行 → 管理端重新保存绑定(走 starter 自注册)"));
|
||||
score -= 40;
|
||||
} else {
|
||||
FlwCallbackReg first = regs.get(0);
|
||||
checks.add(new AppHealthVo.CheckItem("登记地址为网关形态", "ok",
|
||||
abbreviate(first.getCallbackUrl())));
|
||||
}
|
||||
|
||||
// ---- 2) base_path 与 ctx 一致性:注册里 basePath 为空的条数 ----
|
||||
long emptyBase = regs.stream().filter(r -> r.getBasePath() == null || r.getBasePath().isEmpty()).count();
|
||||
if (emptyBase > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("base_path 已登记", "warn",
|
||||
emptyBase + " 条注册未登记 base_path",
|
||||
"漏填 base_path 会导致推送全 401,症状与「收不到回调」同构"));
|
||||
score -= 10;
|
||||
} else {
|
||||
checks.add(new AppHealthVo.CheckItem("base_path 已登记", "ok",
|
||||
regs.get(0).getBasePath()));
|
||||
}
|
||||
|
||||
// ---- 3) 近 7 日推送成功率 ----
|
||||
long success = 0;
|
||||
long pending = 0;
|
||||
long dead = 0;
|
||||
List<Object[]> statusRows = logRepository.countByStatusSinceForRegs(schId, regIds, since);
|
||||
for (Object[] row : statusRows) {
|
||||
int st = ((Number) row[0]).intValue();
|
||||
long cnt = ((Number) row[1]).longValue();
|
||||
if (st == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
|
||||
success = cnt;
|
||||
} else if (st == FlwEnums.CallbackLogStatus.DEAD.getCode()) {
|
||||
dead = cnt;
|
||||
} else {
|
||||
pending = cnt;
|
||||
}
|
||||
}
|
||||
long total = success + pending + dead;
|
||||
if (total == 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "warn", "近 7 日无推送记录"));
|
||||
score -= 5;
|
||||
} else {
|
||||
double rate = success * 100.0 / total;
|
||||
String txt = String.format(java.util.Locale.ROOT, "%.1f%%(%d/%d)", rate, success, total);
|
||||
if (rate >= 99.0) {
|
||||
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "ok", txt));
|
||||
} else if (rate > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "warn", txt,
|
||||
"存在推送失败,查推送日志末次结果定位"));
|
||||
score -= 15;
|
||||
} else {
|
||||
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "bad", txt,
|
||||
"推送全失败:优先核对登记地址与 base_path"));
|
||||
score -= 30;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 4) 挂起 / 死信数 ----
|
||||
long hung = logRepository.countHungForRegs(schId, regIds, HUNG_RETRY_MIN);
|
||||
long deadCnt = logRepository.countByStatusForRegs(schId, regIds,
|
||||
FlwEnums.CallbackLogStatus.DEAD.getCode());
|
||||
if (deadCnt > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("挂起 / 死信数", "bad", hung + " / " + deadCnt,
|
||||
"死信需管理端确认后重推或标记处理"));
|
||||
score -= 20;
|
||||
} else if (hung > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("挂起 / 死信数", "warn", hung + " / 0",
|
||||
"持续重试中,疑似消费方停机"));
|
||||
score -= 12;
|
||||
} else {
|
||||
checks.add(new AppHealthVo.CheckItem("挂起 / 死信数", "ok", "0 / 0"));
|
||||
}
|
||||
|
||||
// ---- 5) 停用注册告警 ----
|
||||
long disabled = regs.stream().filter(r -> r.getStatus() != null && r.getStatus() != 0).count();
|
||||
if (disabled > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("注册启用状态", "warn",
|
||||
disabled + " 条注册已停用", "停用后该流程不再收推送,确认是否为预期"));
|
||||
score -= 8;
|
||||
} else {
|
||||
checks.add(new AppHealthVo.CheckItem("注册启用状态", "ok",
|
||||
regs.size() + " 条全部启用"));
|
||||
}
|
||||
|
||||
AppHealthVo vo = new AppHealthVo();
|
||||
vo.setAppKey(appKey);
|
||||
vo.setAppName(appName == null || appName.isEmpty() ? appKey : appName);
|
||||
vo.setScore(Math.max(0, score));
|
||||
vo.setChecks(checks);
|
||||
|
||||
String level = checks.stream().anyMatch(c -> "bad".equals(c.getLevel())) ? "bad"
|
||||
: checks.stream().anyMatch(c -> "warn".equals(c.getLevel())) ? "warn" : "ok";
|
||||
vo.setLevel(level);
|
||||
vo.setSummary(buildSummary(regs.size(), level, badUrl, deadCnt, hung));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static String buildSummary(int regCount, String level, String badUrl,
|
||||
long dead, long hung) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(regCount == 0 ? "无注册" : regCount + " 条注册");
|
||||
if ("ok".equals(level)) {
|
||||
sb.append(" · 对接正常");
|
||||
return sb.toString();
|
||||
}
|
||||
if (badUrl != null) {
|
||||
sb.append(" · 存在私网登记地址,推送必然失败");
|
||||
}
|
||||
if (dead > 0) {
|
||||
sb.append(" · ").append(dead).append(" 条死信");
|
||||
} else if (hung > 0) {
|
||||
sb.append(" · ").append(hung).append(" 条挂起重试中");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String abbreviate(String url) {
|
||||
if (url == null) {
|
||||
return "-";
|
||||
}
|
||||
return url.length() <= 56 ? url : "…" + url.substring(url.length() - 55);
|
||||
}
|
||||
|
||||
private FlwCallbackReg requireReg(Long schId, Long regId) {
|
||||
FlwCallbackReg reg = regRepository.findById(regId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "回调注册不存在:" + regId));
|
||||
|
||||
Reference in New Issue
Block a user