diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java index 23c0558..1797d94 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -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 callbackHealth(BoaUser user) { + Long schId = adminGuard.requireAdmin(user, null); + java.util.Map 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 下拉与管理页共用) */ diff --git a/src/main/java/com/yunhai/flowcenter/model/AppHealthVo.java b/src/main/java/com/yunhai/flowcenter/model/AppHealthVo.java new file mode 100644 index 0000000..6bd5d24 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/model/AppHealthVo.java @@ -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),零新增表。 + * + *

检查项固定六条(顺序即前端展示顺序),但用 List 承载以便未来增删: + *

    + *
  1. 登记地址形态(是否网关形态,非私网/开发机)
  2. + *
  3. base_path 与应用 ctx 一致性
  4. + *
  5. 最近探测结果(最近的 flow.ping 日志)
  6. + *
  7. 近 7 日推送成功率
  8. + *
  9. 挂起 / 死信数
  10. + *
  11. 有 flowGuid 无登记行(漏注册)
  12. + *
+ */ +@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 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; + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java index f442421..03839d6 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java @@ -35,4 +35,53 @@ public interface FlwCallbackLogRepository extends JpaRepository pageHung(@Param("schId") Long schId, @Param("minRetry") int minRetry, Pageable pageable); List findByIdInAndSchId(Collection 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 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 countByStatusSinceForRegs(@Param("schId") Long schId, + @Param("regIds") Collection 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 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 regIds, + @Param("minRetry") int minRetry); } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java index 873c69b..e8d717d 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java @@ -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; + + /** + * 全量应用健康度体检(每应用一行)。 + * + *

数据全部来自已有表:{@code flw_callback_reg}(登记地址/base_path)、 + * {@code flw_callback_log}(推送成败/死信/挂起)。不新增任何表。 + * + *

应用清单来自注册表 distinct appKey(未接入回调的应用不参与体检—— + * 无注册行本身即"未接入",由向导负责,不是健康度问题)。 + * + * @param appNames appKey → 应用名(来自 flw_app;缺失时回落 appKey) + */ + public List health(Long schId, java.util.Map appNames) { + List allRegs = regRepository.findBySchIdOrderByUpdateTimeDesc(schId); + java.time.LocalDateTime since = java.time.LocalDateTime.now().minusDays(7); + + // 按 appKey 归组(同一应用的多个 flowGuid 注册合并体检) + java.util.Map> 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 out = new java.util.ArrayList<>(); + for (java.util.Map.Entry> 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 regs, java.time.LocalDateTime since) { + List regIds = new java.util.ArrayList<>(); + for (FlwCallbackReg r : regs) { + regIds.add(r.getId()); + } + + List 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 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));