diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java index 95561f32..e4f95ebc 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java @@ -95,4 +95,12 @@ public interface ErrorCodeConstants { /** 双路下发失败:某路 PATCH/publish 失败 → 整批判未生效、已把成功路补偿重推回上一激活版、指针不前移、可重试(设计 §3.6)。占位符带明细。 */ ErrorCode AIGC_CONFIG_ACTIVATE_DISPATCH_FAILED = new ErrorCode(1_101_004_011, "配置激活下发失败(已判未生效并补偿回上一激活版,可重试):{}"); + // ========== 生成配置漂移对账 + 一键重推 1-101-004-012+(配置控制面阶段二 · 步骤5 失败兜底 + 漂移对账)========== + // 口径:对账是「拉 Service/worker 投影比对当前激活版」的只读诊断(不抛异常、把不可达/漂移作数据返回,故对账无错误码); + // 一键重推是「按当前激活版重放双路下发把投影拉回」的写动作,指针已是 ACTIVE 不动、下面两码覆盖其前置与下发失败。续编 004 子段不跳段。 + /** 重推无落点:配置集无当前激活版(active_version_id 为空,从未激活过)→ 无可重推的内容(设计 §3.6:重推 = 重放当前激活版)。 */ + ErrorCode AIGC_CONFIG_REBROADCAST_NO_ACTIVE = new ErrorCode(1_101_004_012, "配置集无当前激活版,无可重推的内容"); + /** 一键重推下发失败:按当前激活版重放双路下发时某路失败 → 当前激活版指针不变(本就是 ACTIVE、重推只推投影不动账本)、可重试(设计 §3.6)。占位符带明细。 */ + ErrorCode AIGC_CONFIG_REBROADCAST_FAILED = new ErrorCode(1_101_004_013, "配置重推下发失败(当前激活版指针不变、可重试):{}"); + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigController.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigController.java index 433fd086..c572a636 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigController.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigController.java @@ -1,6 +1,7 @@ package com.wanxiang.huijing.game.module.aigc.controller.admin.config; import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigActivateReqVO; +import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigDriftReportRespVO; import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigFreezeReqVO; import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetCreateReqVO; import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetPageReqVO; @@ -11,6 +12,7 @@ import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfi import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigSetDO; import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigVersionDO; import com.wanxiang.huijing.game.module.aigc.service.config.GenConfigActivationService; +import com.wanxiang.huijing.game.module.aigc.service.config.GenConfigReconcileService; import com.wanxiang.huijing.game.module.aigc.service.config.GenConfigService; import com.wanxiang.huijing.framework.apilog.core.annotation.ApiAccessLog; import com.wanxiang.huijing.framework.common.pojo.CommonResult; @@ -35,6 +37,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.stream.Collectors; +import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.GET; import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.UPDATE; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; @@ -63,6 +66,9 @@ public class AdminGenConfigController { @Resource private GenConfigActivationService genConfigActivationService; + @Resource + private GenConfigReconcileService genConfigReconcileService; + // ==================== 配置集:建/改/查/归档 ==================== @PostMapping("/create") @@ -183,4 +189,29 @@ public class AdminGenConfigController { return success(true); } + // ==================== 漂移对账 / 一键重推(步骤5) ==================== + + @GetMapping("/reconcile") + @Operation(summary = "配置漂移对账", + description = "只读拉两路投影(路A Service 的 agent/session、路B Nacos 生效 dataId)比对当前激活版,产结构化漂移报告;" + + "严格区分激活中间态跳过/系统不可达/最终一致延迟三种非漂移态、不误报;不改任何状态") + @Parameter(name = "configSetId", description = "配置集 ID", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('aigc:config:query')") // 只读诊断:沿子域既有 query 权限串 + @ApiAccessLog(operateType = GET) + public CommonResult reconcile(@RequestParam("configSetId") Long configSetId) { + return success(genConfigReconcileService.reconcile(configSetId)); + } + + @PostMapping("/rebroadcast") + @Operation(summary = "一键重推当前激活版", + description = "对账检出漂移后把当前激活版重放双路下发、拉回投影;指针本就是当前激活版、保持不动(不落 ACTIVATING/不写账本);" + + "幂等;某路失败即整批可重试(另一路已收敛、指针不变)。重推后可再对账验证收敛") + @Parameter(name = "configSetId", description = "配置集 ID", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('aigc:config:activate')") // 写投影:与激活同权限 + @ApiAccessLog(operateType = UPDATE) // yudao 操作日志:重推归因到人 + 可审计 + public CommonResult rebroadcast(@RequestParam("configSetId") Long configSetId) { + genConfigActivationService.rebroadcastActive(configSetId); + return success(true); + } + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigDriftReportRespVO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigDriftReportRespVO.java new file mode 100644 index 00000000..c80ac918 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigDriftReportRespVO.java @@ -0,0 +1,99 @@ +package com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 配置漂移对账报告 Response VO(admin 端,配置控制面阶段二 · 步骤5) + * + *

对账 = 拉「生效投影」(Service 的 agent/session 路 A、Nacos 生效 dataId 路 B) 与「当前激活版」应有值逐项比对,产出结构化漂移报告 + * (设计 §3.6)。核心是严格区分三种「非漂移态」不误报:① 配置集正 ACTIVATING → 激活中间态、跳过比对;② Service/Nacos + * 不可达 → 可用性问题、非漂移;③ 路 B 读用轮询容忍窗吸收 Nacos publish→read 的最终一致延迟(改值后立即读可能返旧值)。 + * + *

{@link #verdict} 五态(顶层裁定,取值见字段说明):{@code IN_SYNC}(投影与激活版一致) / {@code DRIFT}(检出漂移) / + * {@code ACTIVATING_SKIPPED}(激活中间态跳过) / {@code NO_ACTIVE_VERSION}(无激活版、无可对账) / {@code UNREACHABLE} + * (至少一路不可达且无漂移可判)。每路(A/B)另给 {@link RouteReconcileVO} 明细,漂移项 {@link DriftItemVO} 给「路/键/期望/实际」。 + * + * @author 绘境AI + */ +@Schema(description = "管理后台 - 配置漂移对账报告 Response VO") +@Data +public class GenConfigDriftReportRespVO { + + @Schema(description = "配置集 ID", example = "1024") + private Long configSetId; + + @Schema(description = "档位(cheap/tier2)", example = "cheap") + private String tier; + + @Schema(description = "当前激活版本行 ID(NULL=从未激活)", example = "2048") + private Long activeVersionId; + + @Schema(description = "顶层对账裁定:IN_SYNC 一致 / DRIFT 检出漂移 / ACTIVATING_SKIPPED 激活中间态跳过 / " + + "NO_ACTIVE_VERSION 无激活版 / UNREACHABLE 有路不可达且无漂移可判", example = "DRIFT") + private String verdict; + + @Schema(description = "人读结论摘要", example = "路B检出1项漂移:budget.cheap_rmb_hard_limit 期望10.0实际999.0") + private String summary; + + @Schema(description = "路 A(Service 的 agent system_prompt + session chat_model_config)对账明细") + private RouteReconcileVO pathA; + + @Schema(description = "路 B(Nacos 生效 dataId 的 area.key 预算/门阈值)对账明细") + private RouteReconcileVO pathB; + + @Schema(description = "全部漂移项(两路汇总;每项含 路/键/期望/实际)") + private List driftItems; + + /** + * 单路对账明细(路 A / 路 B 各一份) + */ + @Schema(description = "单路对账明细") + @Data + public static class RouteReconcileVO { + + @Schema(description = "路标识:A(Service PATCH 投影)/ B(Nacos 生效 dataId)", example = "B") + private String route; + + @Schema(description = "当前激活版是否有这一路(false=该激活版不下发此路、跳过对账)", example = "true") + private Boolean applicable; + + @Schema(description = "投影系统是否可达(false=不可达,可用性问题非漂移)", example = "true") + private Boolean reachable; + + @Schema(description = "可达前提下投影是否存在(false=投影缺失,如 Service 未装配该 agent/session、Nacos dataId 为空)", example = "true") + private Boolean projectionFound; + + @Schema(description = "人读说明(不可达/投影缺失/一致/检出N项漂移)", example = "检出1项漂移") + private String note; + + @Schema(description = "本路漂移项") + private List driftItems; + + } + + /** + * 单个漂移项(路/键/期望/实际) + */ + @Schema(description = "单个漂移项") + @Data + public static class DriftItemVO { + + @Schema(description = "路标识:A / B", example = "B") + private String route; + + @Schema(description = "旋钮键(路A:system_prompt / chat_model_config.model 等;路B:area.key 如 budget.cheap_rmb_hard_limit)", + example = "budget.cheap_rmb_hard_limit") + private String key; + + @Schema(description = "当前激活版应有值(期望;大字段如 system_prompt 以 len=N sha256=.. 摘要表示)", example = "10.0") + private String expected; + + @Schema(description = "投影实际值(缺失时为「(缺失)」)", example = "999.0") + private String actual; + + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AgentServicePatchClient.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AgentServicePatchClient.java index 5a173e30..f8c831df 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AgentServicePatchClient.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AgentServicePatchClient.java @@ -1,5 +1,7 @@ package com.wanxiang.huijing.game.module.aigc.service.config; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigTierEnum; import lombok.extern.slf4j.Slf4j; @@ -33,6 +35,9 @@ import java.util.Map; @Slf4j public class AgentServicePatchClient { + /** 只读投影解析用 JSON(无共享可变状态,线程安全)。 */ + private static final ObjectMapper JSON = new ObjectMapper(); + private final AigcConfigActivationProperties props; private final GenConfigHttpClient.HttpExchange http; @@ -74,6 +79,120 @@ public class AgentServicePatchClient { return doPatch("路A/session", sessionUrl, headers, pathA.sessionBodyJson()); } + // ==================== 只读投影探测(步骤5 漂移对账用;不改任何 Service 状态)==================== + + /** + * 读 Service 当前 agent 投影的 system_prompt(漂移对账路 A:与当前激活版应有 system_prompt 比对,设计 §3.6)。 + * + *

为何走列表端点:AgentScope 2.0.2 的 agent 路由没有「单个 GET /agent/{id}」(只有 {@code GET /agent/} + * 列出该 user 全部 agent、{@code GET /agent/schema}、以及 POST/PATCH/DELETE)。故读单个 agent 投影 = {@code GET /agent/} + * 列表后按 {@code agents[].id == agentId} 过滤,取其 {@code data.system_prompt}。带 X-User-ID 鉴权头(与 PATCH 同 user)。 + * + * @param tier 档位(决定打哪个 Service 地址) + * @param agentId 目标 agent id(PATCH 路 A 的同一 agentId) + * @return 三态探测结果:不可达(Service 联系不上)/ 投影缺失(列表里无此 agent)/ 投影在(带 system_prompt 文本) + */ + public GenConfigProbeResult probeAgentSystemPrompt(String tier, String agentId) { + String base = resolveServiceUrl(tier); + if (base == null || base.isBlank()) { + log.error("[gen-config-reconcile][路A] Service 地址未配置 tier={},无法读 agent 投影", tier); + return GenConfigProbeResult.unreachable(); + } + String url = trimTrailingSlash(base) + "/agent/"; + JsonNode listRoot = getJson("路A/agent-list", url); + if (listRoot == null) { + return GenConfigProbeResult.unreachable(); // 联系不上(超时/连接拒绝/非2xx),不算漂移 + } + JsonNode agents = listRoot.get("agents"); + if (agents == null || !agents.isArray()) { + log.warn("[gen-config-reconcile][路A] agent 列表结构非预期(无 agents 数组) url={}", url); + return GenConfigProbeResult.missing(); + } + for (JsonNode rec : agents) { + if (rec.path("id").asText("").equals(agentId)) { + JsonNode data = rec.get("data"); + String sp = (data != null) ? data.path("system_prompt").asText("") : ""; + return GenConfigProbeResult.found(sp); + } + } + // 可达但列表里无此 agent = 投影缺失(Service 未装配该 agent;显式后续增强位:Service 启动自愈,设计 §6) + return GenConfigProbeResult.missing(); + } + + /** + * 读 Service 当前 session 投影的 chat_model_config(漂移对账路 A:与当前激活版应有模型配置逐字段比对,设计 §3.3/§3.6)。 + * + *

同理 AgentScope 2.0.2 无「单个 GET /sessions/{id}」(只有 {@code GET /sessions/?agent_id=} 列表、 + * {@code /sessions/{id}/messages}、{@code /stream})。故 = {@code GET /sessions/?agent_id=X} 列表后按 + * {@code sessions[].session.id == sessionId} 过滤,取其 {@code session.config.chat_model_config}。model/参数都封在这个子对象里 + * (PATCH 时整替,设计 §3.3),故读回整个子对象供逐字段比对。 + * + * @param tier 档位 + * @param agentId session 归属的 agent id({@code /sessions/} 必带 agent_id query) + * @param sessionId 目标 session id + * @return 三态探测结果:不可达 / 投影缺失(无此 session 或未配模型)/ 投影在(带 chat_model_config JSON 文本) + */ + public GenConfigProbeResult probeSessionModelConfig(String tier, String agentId, String sessionId) { + String base = resolveServiceUrl(tier); + if (base == null || base.isBlank()) { + log.error("[gen-config-reconcile][路A] Service 地址未配置 tier={},无法读 session 投影", tier); + return GenConfigProbeResult.unreachable(); + } + String url = trimTrailingSlash(base) + "/sessions/?agent_id=" + enc(agentId); + JsonNode listRoot = getJson("路A/session-list", url); + if (listRoot == null) { + return GenConfigProbeResult.unreachable(); + } + JsonNode sessions = listRoot.get("sessions"); + if (sessions == null || !sessions.isArray()) { + log.warn("[gen-config-reconcile][路A] session 列表结构非预期(无 sessions 数组) url={}", url); + return GenConfigProbeResult.missing(); + } + for (JsonNode view : sessions) { + JsonNode session = view.get("session"); + if (session != null && session.path("id").asText("").equals(sessionId)) { + JsonNode cmc = session.path("config").get("chat_model_config"); + if (cmc == null || cmc.isNull()) { + // session 在但尚未配模型 = 投影缺失(chat_model_config 未装配) + return GenConfigProbeResult.missing(); + } + return GenConfigProbeResult.found(writeCompact(cmc)); + } + } + return GenConfigProbeResult.missing(); + } + + /** 发一次只读 GET(带 X-User-ID);2xx 解析为 JsonNode 返回,非2xx/超时/异常一律返回 null(= 不可达,交调用方按不可达处置)。 */ + private JsonNode getJson(String tag, String url) { + Map headers = new LinkedHashMap<>(); + headers.put("X-User-ID", props.getServiceUserId()); // AgentScope 鉴权头(与 PATCH 同 user,否则读不到自己的 agent/session) + long start = System.currentTimeMillis(); + try { + GenConfigHttpClient.HttpResult resp = http.exchange("GET", url, headers, null, + props.getServicePatchTimeoutSeconds()); + long cost = System.currentTimeMillis() - start; + if (!resp.ok()) { + log.warn("[gen-config-reconcile][{}] GET 非2xx url={} http={} 耗时={}ms(判不可达)", tag, url, resp.statusCode(), cost); + return null; + } + return JSON.readTree(resp.body()); + } catch (Exception e) { + long cost = System.currentTimeMillis() - start; + log.error("[gen-config-reconcile][{}] GET 通道异常 url={} 耗时={}ms err={}: {}(判不可达)", + tag, url, cost, e.getClass().getSimpleName(), e.getMessage()); + return null; + } + } + + /** JsonNode 序列化为紧凑 JSON 文本(投影值载体);失败返回节点的字符串形态(防御,不抛穿)。 */ + private static String writeCompact(JsonNode node) { + try { + return JSON.writeValueAsString(node); + } catch (Exception e) { + return node.toString(); + } + } + /** 发一次 PATCH(2xx=ok;HTTP 错/超时/异常都归 fail 并留痕)。 */ private GenConfigDispatchResult doPatch(String tag, String url, Map headers, String body) { long start = System.currentTimeMillis(); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AigcConfigActivationConfiguration.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AigcConfigActivationConfiguration.java index 80f496b0..10e3642c 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AigcConfigActivationConfiguration.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AigcConfigActivationConfiguration.java @@ -71,4 +71,24 @@ public class AigcConfigActivationConfiguration { return new GenConfigActivationServiceImpl(genConfigService, codec, agentClient, nacosClient); } + /** + * 漂移对账 Service(步骤5):只读拉两路投影比对当前激活版,产结构化漂移报告。与激活编排共用同一批 clients/codec/props + * (对账的「期望」= 激活时真正下发的口径,故复用同一 codec 保证逐字一致)。 + * + * @param genConfigService 配置治理 Service(读配置集/激活版) + * @param codec 内容编解码器(组装期望载荷) + * @param agentClient 路 A 客户端(只读投影 GET /agent/、/sessions/) + * @param nacosClient 路 B 客户端(只读投影 probe dataId) + * @param props 激活编排配置(含路 B 对账最终一致容忍窗) + * @return 漂移对账 Service + */ + @Bean + public GenConfigReconcileService genConfigReconcileService(GenConfigService genConfigService, + GenConfigContentCodec codec, + AgentServicePatchClient agentClient, + NacosConfigPublishClient nacosClient, + AigcConfigActivationProperties props) { + return new GenConfigReconcileServiceImpl(genConfigService, codec, agentClient, nacosClient, props); + } + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AigcConfigActivationProperties.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AigcConfigActivationProperties.java index 9a5db2b0..c9d41a4a 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AigcConfigActivationProperties.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/AigcConfigActivationProperties.java @@ -87,4 +87,15 @@ public class AigcConfigActivationProperties { /** 超时类旋钮(budget.*timeout_s* / design_team.timeout_s)的下限秒数:低于即判配得过小、会误杀在跑生成,拒绝激活。默认 30s。 */ private Double timeoutFloorSeconds = 30.0; + // ===== 步骤5 漂移对账:路 B 最终一致容忍窗(设计 §3.6 / C 波真发现:Nacos publish→read 最终一致,改值后立即读可能返旧值)===== + + /** + * 路 B 对账读的容忍窗总时长(秒):Nacos publish→read 是最终一致(服务端缓存滞后一拍),刚激活/重推后立即对账可能读到旧值, + * 若不容忍会把「传播延迟」误判成「漂移」。故路 B 对账在窗内轮询:比对一致即早退,直到窗满仍不一致才判为真漂移。默认 8s。 + */ + private Integer reconcileNacosToleranceSeconds = 8; + + /** 路 B 对账容忍窗内的轮询间隔(毫秒;每隔此时长重读 dataId 再比对)。默认 800ms。 */ + private Long reconcileNacosPollMillis = 800L; + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationService.java index e1798530..c4302a28 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationService.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationService.java @@ -59,4 +59,20 @@ public interface GenConfigActivationService { */ void recoverActivating(Long configSetId); + /** + * 一键重推:按当前激活版重放双路下发,把已漂移的生效投影拉回当前激活版(配置控制面阶段二 · 步骤5 漂移兜底)。 + * + *

与 {@link #activate} 的关键区别是不动账本:重推的目标就是当前激活版本身,当前激活版指针已经是它、保持不动, + * 不落 ACTIVATING、不前移指针、不写激活审计——重推只把「投影」重新推成「账本已认定的当前激活版」,是投影向账本的收敛, + * 不是一次新的激活。故也无需补偿:重推推的就是权威内容,某路失败只是这一路没收敛(另一路已收敛回激活版), + * 判可重试即可,绝不会把投影推离激活版(设计 §3.6:对账检出漂移后一键重推把投影拉回激活版)。 + * + *

幂等:同一激活版重复重推无副作用(PATCH/publish 都是同值覆盖)。前置:配置集须有当前激活版 + * (否则 AIGC_CONFIG_REBROADCAST_NO_ACTIVE);正处 ACTIVATING(有在途激活)时拒绝重推(请先 recover,避免与在途激活相互踩)。 + * + * @param configSetId 配置集 ID + * @throws com.wanxiang.huijing.framework.common.exception.ServiceException 无当前激活版 / 正激活中 / 某路下发失败(可重试) + */ + void rebroadcastActive(Long configSetId); + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImpl.java index 0a6434bd..4b71423d 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImpl.java @@ -7,6 +7,8 @@ import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigTierEnum; import lombok.extern.slf4j.Slf4j; import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_ACTIVATE_DISPATCH_FAILED; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_REBROADCAST_FAILED; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_REBROADCAST_NO_ACTIVE; import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_TIER_INVALID; import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -128,6 +130,45 @@ public class GenConfigActivationServiceImpl implements GenConfigActivationServic } } + @Override + public void rebroadcastActive(Long configSetId) { + GenConfigSetDO set = genConfigService.getConfigSet(configSetId); + String tier = set.getTier(); + if (!AigcConfigTierEnum.isValid(tier)) { + throw exception(AIGC_CONFIG_TIER_INVALID); + } + // 正激活中:有在途激活,重推会与在途激活相互踩(都写同批投影)→ 拒绝,请先 recover 收敛再重推 + if (AigcConfigStatusEnum.ACTIVATING.getStatus().equals(set.getStatus())) { + log.warn("[gen-config-rebroadcast] 配置集正在激活中,拒绝重推 setId={} activatingVersionId={}", + configSetId, set.getActivatingVersionId()); + throw exception(AIGC_CONFIG_REBROADCAST_FAILED, "配置集正在激活中,请先恢复(recover)后再重推"); + } + // 无当前激活版:无可重推的内容(重推 = 重放当前激活版把投影拉回,没有激活版就没有落点) + Long activeVersionId = set.getActiveVersionId(); + if (activeVersionId == null) { + log.warn("[gen-config-rebroadcast] 配置集无当前激活版,无可重推内容 setId={}", configSetId); + throw exception(AIGC_CONFIG_REBROADCAST_NO_ACTIVE); + } + + // 取当前激活版内容 → 组装载荷(sanity 再校验一遍;当前激活版本就激活过、必过)→ 复用 C 波私有 dispatch 双路重推。 + GenConfigVersionDO version = genConfigService.getVersion(configSetId, activeVersionId); + GenConfigActivationPayload payload = codec.build(version.getPrompt(), version.getContentJson()); + log.info("[gen-config-rebroadcast] 开始重推当前激活版 setId={} tier={} activeVersionId={} hasPathA={} hasPathB={}(指针不动)", + configSetId, tier, activeVersionId, payload.hasPathA(), payload.hasPathB()); + + DispatchOutcome out = dispatch(tier, payload); // 幂等重放(投影已同值则等价 no-op);不动指针、不补偿(推的就是权威内容) + if (out.allOk()) { + // 收敛成功:账本无需任何写(指针本就是 activeVersionId、状态本就是 ACTIVE),投影已拉回当前激活版 + log.info("[gen-config-rebroadcast] 重推成功、投影已收敛回当前激活版 setId={} activeVersionId={}", + configSetId, activeVersionId); + return; + } + // 某路失败:另一路已收敛回激活版、指针本就未动(无需补偿/复位,区别于 activate 的失败路);判可重试。 + log.error("[gen-config-rebroadcast] 重推某路失败(指针不变、可重试) setId={} activeVersionId={} detail={}", + configSetId, activeVersionId, out.failDetail()); + throw exception(AIGC_CONFIG_REBROADCAST_FAILED, out.failDetail()); + } + // ==================== 内部:双路下发 + 补偿 ==================== /** diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigProbeResult.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigProbeResult.java new file mode 100644 index 00000000..d5949b1c --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigProbeResult.java @@ -0,0 +1,38 @@ +package com.wanxiang.huijing.game.module.aigc.service.config; + +/** + * 投影探测结果(配置控制面阶段二 · 步骤5 漂移对账):读一处生效投影(Service 的 agent/session、Nacos 生效 dataId)时的三态结果。 + * + *

漂移对账要严格区分「真漂移」「投影缺失」「系统不可达」三种情形(设计 §3.6),三者对应不同处置:不可达是可用性问题不算漂移、 + * 投影缺失是显式后续增强位、只有「可达且投影在但值不符」才是真漂移。故读投影不能只返回一个「值 or null」(那会把不可达与缺失 + * 混为一谈),而要三态: + *

+ * + * @param reachable 目标系统是否可达(能完成一次读交互;false=不可达,不算漂移) + * @param found 可达前提下目标投影是否存在(false=投影缺失) + * @param value 投影值(found=true 时非空:system_prompt 文本 / chat_model_config JSON / dataId 内容文本;否则 null) + * @author 绘境AI + */ +public record GenConfigProbeResult(boolean reachable, boolean found, String value) { + + /** 不可达(可用性问题,非漂移)。 */ + public static GenConfigProbeResult unreachable() { + return new GenConfigProbeResult(false, false, null); + } + + /** 可达但投影缺失(目标不存在)。 */ + public static GenConfigProbeResult missing() { + return new GenConfigProbeResult(true, false, null); + } + + /** 可达且投影在(带投影值)。 */ + public static GenConfigProbeResult found(String value) { + return new GenConfigProbeResult(true, true, value); + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileService.java new file mode 100644 index 00000000..fda0c3a3 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileService.java @@ -0,0 +1,37 @@ +package com.wanxiang.huijing.game.module.aigc.service.config; + +import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigDriftReportRespVO; + +/** + * 生成配置漂移对账 Service(配置控制面阶段二 · 步骤5 失败兜底 + 漂移对账) + * + *

「配置中心为权威、Service 与 worker 为投影」这个定位需要一道兜底闸:投影可能与当前激活版漂移——Service 重启若 Redis 未持久化 + * 会丢配置、有人绕过治理直接改了 Redis/Nacos、某次激活部分失败没补偿干净、或机器上遗留 env 压过了本该生效的激活值(设计 §3.6)。 + * 本 Service 提供只读对账:拉路 A(Service 的 agent system_prompt + session chat_model_config)与路 B(Nacos 生效 dataId 的 + * 预算/门阈值 area.key)当前投影,与当前激活版应有值逐项比对,产出结构化漂移报告。 + * + *

严格区分三种「非漂移态」不误报(这是本步最核心的正确性要求): + *

+ * 对账本身是「漂移的检测面」而非「失败的第一道防线」——第一道是激活链路的即时补偿(步骤3 已交付);对账查出漂移后由 + * {@link GenConfigActivationService#rebroadcastActive} 一键重推把投影拉回当前激活版。 + * + * @author 绘境AI + */ +public interface GenConfigReconcileService { + + /** + * 对一个配置集做漂移对账(只读,不改任何投影/账本状态)。 + * + * @param configSetId 配置集 ID + * @return 结构化漂移报告(顶层裁定五态 + 路 A/路 B 明细 + 漂移项「路/键/期望/实际」) + * @throws com.wanxiang.huijing.framework.common.exception.ServiceException 配置集不存在(AIGC_CONFIG_SET_NOT_EXISTS)/ + * 档位非法(AIGC_CONFIG_TIER_INVALID,数据异常);系统不可达不抛(作数据返回) + */ + GenConfigDriftReportRespVO reconcile(Long configSetId); + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImpl.java new file mode 100644 index 00000000..48561978 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImpl.java @@ -0,0 +1,419 @@ +package com.wanxiang.huijing.game.module.aigc.service.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigDriftReportRespVO; +import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigDriftReportRespVO.DriftItemVO; +import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigDriftReportRespVO.RouteReconcileVO; +import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigSetDO; +import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigVersionDO; +import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum; +import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigTierEnum; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_TIER_INVALID; +import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 生成配置漂移对账实现(配置控制面阶段二 · 步骤5) + * + *

不改任何状态的只读诊断:取当前激活版内容 → 经 {@link GenConfigContentCodec} 组装出「与激活时下发口径一致」的期望载荷 → + * 拉路 A(Service 的 agent/session 投影)与路 B(Nacos 生效 dataId 投影)→ 逐项比对 → 产出结构化漂移报告。三种非漂移态 + * (激活中间态 / 不可达 / 最终一致延迟)严格分开,避免误报(见接口 Javadoc 与设计 §3.6)。 + * + *

Bean 注册经 {@link AigcConfigActivationConfiguration}(与两路客户端、codec 同处装配,单测可注入桩),不标 @Service。 + * + * @author 绘境AI + */ +@Slf4j +public class GenConfigReconcileServiceImpl implements GenConfigReconcileService { + + private static final ObjectMapper JSON = new ObjectMapper(); + /** 投影缺失的占位实际值(报告里 actual 显示为它)。 */ + private static final String MISSING = "(缺失)"; + /** 期望侧不该存在(投影里有、激活版没有的多余键)的占位期望值。 */ + private static final String UNEXPECTED = "(激活版无此键)"; + /** 大字段(system_prompt 等)值摘要阈值:超过则以 len + sha256 摘要表示,避免报告塞进整段 prompt。 */ + private static final int SUMMARIZE_OVER = 120; + + private final GenConfigService genConfigService; + private final GenConfigContentCodec codec; + private final AgentServicePatchClient agentClient; + private final NacosConfigPublishClient nacosClient; + private final AigcConfigActivationProperties props; + + public GenConfigReconcileServiceImpl(GenConfigService genConfigService, GenConfigContentCodec codec, + AgentServicePatchClient agentClient, NacosConfigPublishClient nacosClient, + AigcConfigActivationProperties props) { + this.genConfigService = genConfigService; + this.codec = codec; + this.agentClient = agentClient; + this.nacosClient = nacosClient; + this.props = props; + } + + @Override + public GenConfigDriftReportRespVO reconcile(Long configSetId) { + GenConfigSetDO set = genConfigService.getConfigSet(configSetId); // 不存在即抛(AIGC_CONFIG_SET_NOT_EXISTS) + GenConfigDriftReportRespVO report = new GenConfigDriftReportRespVO(); + report.setConfigSetId(configSetId); + report.setTier(set.getTier()); + report.setActiveVersionId(set.getActiveVersionId()); + report.setDriftItems(new ArrayList<>()); + + // 非漂移态①:ACTIVATING 激活中间态 → 跳过比对(否则会把「激活在途、投影正被逐路写入」误判成漂移,设计 §3.6 风险) + if (AigcConfigStatusEnum.ACTIVATING.getStatus().equals(set.getStatus())) { + report.setVerdict("ACTIVATING_SKIPPED"); + report.setSummary("配置集正处激活中间态(ACTIVATING),跳过对账以免把激活在途误判为漂移;请待激活收敛或先 recover 后再对账"); + log.info("[gen-config-reconcile] 激活中间态跳过对账 setId={} activatingVersionId={}", + configSetId, set.getActivatingVersionId()); + return report; + } + // 无当前激活版:无可对账的应有值(从未激活过) + Long activeVersionId = set.getActiveVersionId(); + if (activeVersionId == null) { + report.setVerdict("NO_ACTIVE_VERSION"); + report.setSummary("配置集尚无当前激活版(从未激活),无可对账的应有值"); + log.info("[gen-config-reconcile] 无激活版、无可对账 setId={}", configSetId); + return report; + } + String tier = set.getTier(); + if (!AigcConfigTierEnum.isValid(tier)) { + // 档位非法(数据异常):无从解析投影通道 → 抛(数据问题、非可用性问题) + throw exception(AIGC_CONFIG_TIER_INVALID); + } + + // 取当前激活版 → 组装期望载荷(复用激活编解码器,与激活时真正下发的口径逐字一致) + GenConfigVersionDO version = genConfigService.getVersion(configSetId, activeVersionId); + GenConfigActivationPayload payload = codec.build(version.getPrompt(), version.getContentJson()); + + RouteReconcileVO pathA = reconcilePathA(tier, version, payload); + RouteReconcileVO pathB = reconcilePathB(tier, payload); + report.setPathA(pathA); + report.setPathB(pathB); + report.getDriftItems().addAll(pathA.getDriftItems()); + report.getDriftItems().addAll(pathB.getDriftItems()); + + applyVerdict(report, pathA, pathB); + log.info("[gen-config-reconcile] 对账完成 setId={} tier={} activeVersionId={} verdict={} 漂移项={} 路A[{}] 路B[{}]", + configSetId, tier, activeVersionId, report.getVerdict(), report.getDriftItems().size(), + pathA.getNote(), pathB.getNote()); + return report; + } + + // ==================== 路 A:Service 的 agent system_prompt + session chat_model_config ==================== + + private RouteReconcileVO reconcilePathA(String tier, GenConfigVersionDO version, GenConfigActivationPayload payload) { + RouteReconcileVO r = newRoute("A"); + if (!payload.hasPathA()) { + r.setApplicable(false); + r.setNote("当前激活版无路A(不下发 prompt/模型/参数),跳过"); + return r; + } + r.setApplicable(true); + GenConfigActivationPayload.PathA a = payload.pathA(); + + // 期望:system_prompt = 版本 prompt 正文(codec 已保证有路A→prompt 非空);chat_model_config = sessionBodyJson 里的子对象 + String expectedSystemPrompt = nz(version.getPrompt()); + JsonNode expectedCmc = extractChatModelConfig(a.sessionBodyJson()); + + // 读两处投影(agent→system_prompt;session→chat_model_config) + GenConfigProbeResult agentProbe = agentClient.probeAgentSystemPrompt(tier, a.agentId()); + GenConfigProbeResult sessionProbe = agentClient.probeSessionModelConfig(tier, a.agentId(), a.sessionId()); + + // 不可达(任一探测联系不上 Service)→ 整路判不可达(可用性问题,非漂移;不据此产任何漂移项) + if (!agentProbe.reachable() || !sessionProbe.reachable()) { + r.setReachable(false); + r.setProjectionFound(false); + r.setNote("Service 不可达(可用性问题,非漂移)"); + return r; + } + r.setReachable(true); + + boolean anyMissing = false; + // ① system_prompt 比对 + if (!agentProbe.found()) { + anyMissing = true; + r.getDriftItems().add(item("A", "system_prompt", summarize(expectedSystemPrompt), MISSING)); + } else if (!nz(agentProbe.value()).equals(expectedSystemPrompt)) { + r.getDriftItems().add(item("A", "system_prompt", summarize(expectedSystemPrompt), summarize(agentProbe.value()))); + } + // ② chat_model_config 逐字段比对 + if (!sessionProbe.found()) { + anyMissing = true; + r.getDriftItems().add(item("A", "chat_model_config", summarize(text(expectedCmc)), MISSING)); + } else { + JsonNode actualCmc = parseOrNull(sessionProbe.value()); + r.getDriftItems().addAll(diffNode("A", "chat_model_config", expectedCmc, actualCmc)); + } + r.setProjectionFound(!anyMissing); + r.setNote(noteFor(r)); + return r; + } + + // ==================== 路 B:Nacos 生效 dataId 的 area.key 预算/门阈值(带最终一致容忍窗) ==================== + + private RouteReconcileVO reconcilePathB(String tier, GenConfigActivationPayload payload) { + RouteReconcileVO r = newRoute("B"); + if (!payload.hasPathB()) { + r.setApplicable(false); + r.setNote("当前激活版无路B(不下发预算/门阈值),跳过"); + return r; + } + r.setApplicable(true); + String dataId = nacosClient.resolveDataId(tier); + Map expected = flatten(parseOrNull(payload.pathBJson())); + + // 容忍窗轮询:吸收 Nacos publish→read 最终一致延迟(改值/重推后立即读可能返旧值,C 波真栽过)—— + // 窗内每读一次即比对,一致就早退;直到窗满仍不一致,才把「最后一次读到的差异」判为真漂移。 + long deadline = System.currentTimeMillis() + Math.max(0L, props.getReconcileNacosToleranceSeconds() * 1000L); + long pollMs = Math.max(50L, props.getReconcileNacosPollMillis()); + List drift = new ArrayList<>(); + boolean projectionFound = false; + while (true) { + GenConfigProbeResult probe = nacosClient.probeDataId(dataId); + if (!probe.reachable()) { + // 不可达:不轮询、不判漂移(可用性问题) + r.setReachable(false); + r.setProjectionFound(false); + r.setNote("Nacos 不可达(可用性问题,非漂移)"); + return r; + } + if (!probe.found()) { + // 投影缺失:dataId 为空 → 每个期望键都记缺失(仍可能是传播中,故继续在窗内轮询等它出现) + projectionFound = false; + drift = new ArrayList<>(); + for (Map.Entry e : expected.entrySet()) { + drift.add(item("B", e.getKey(), text(e.getValue()), MISSING)); + } + } else { + projectionFound = true; + drift = diffFlat("B", expected, flatten(parseOrNull(probe.value()))); + } + if (drift.isEmpty() || System.currentTimeMillis() >= deadline) { + break; // 一致(早退) 或 容忍窗到期(仍差异 → 判真漂移) + } + sleep(pollMs); // 可能是最终一致传播延迟,等一拍再读 + } + r.setReachable(true); + r.setProjectionFound(projectionFound); + r.getDriftItems().addAll(drift); + r.setNote(noteFor(r)); + return r; + } + + // ==================== 顶层裁定 ==================== + + private void applyVerdict(GenConfigDriftReportRespVO report, RouteReconcileVO pathA, RouteReconcileVO pathB) { + boolean anyDrift = !report.getDriftItems().isEmpty(); + boolean anyUnreachable = applicableUnreachable(pathA) || applicableUnreachable(pathB); + if (anyDrift) { + report.setVerdict("DRIFT"); // 有真漂移优先裁 DRIFT(即便另一路不可达,报告里各路明细仍如实标注) + } else if (anyUnreachable) { + report.setVerdict("UNREACHABLE"); // 无漂移但有路不可达 → 可用性问题,未能完成完整对账 + } else { + report.setVerdict("IN_SYNC"); + } + report.setSummary(buildSummary(report, pathA, pathB)); + } + + /** 该路「适用且不可达」(不适用的路不计入不可达)。 */ + private static boolean applicableUnreachable(RouteReconcileVO r) { + return Boolean.TRUE.equals(r.getApplicable()) && Boolean.FALSE.equals(r.getReachable()); + } + + private String buildSummary(GenConfigDriftReportRespVO report, RouteReconcileVO pathA, RouteReconcileVO pathB) { + return switch (report.getVerdict()) { + case "IN_SYNC" -> "投影与当前激活版一致(路A:" + pathA.getNote() + ";路B:" + pathB.getNote() + ")"; + case "UNREACHABLE" -> "未检出漂移,但有投影系统不可达(路A:" + pathA.getNote() + ";路B:" + pathB.getNote() + + ")——可用性问题,恢复后重对账"; + case "DRIFT" -> "检出" + report.getDriftItems().size() + "项漂移(路A:" + pathA.getNote() + ";路B:" + pathB.getNote() + + ")——可一键重推把投影拉回当前激活版"; + default -> report.getSummary(); + }; + } + + private String noteFor(RouteReconcileVO r) { + if (Boolean.FALSE.equals(r.getReachable())) { + return "不可达"; + } + List items = r.getDriftItems(); + if (items.isEmpty()) { + return "一致"; + } + long missing = items.stream().filter(d -> MISSING.equals(d.getActual())).count(); + if (missing == items.size()) { + // 全为投影缺失:Service 未装配该 agent/session、或 Nacos dataId 为空。Service 启动自愈是显式后续增强位(设计 §6,本步不做), + // 本步的收敛手段 = 一键重推(rebroadcastActive)把投影推回当前激活版。 + return "投影缺失(" + missing + "项)——后续增强位:Service启动自愈/一键重推收敛"; + } + return "检出" + items.size() + "项漂移" + (missing > 0 ? "(含投影缺失" + missing + "项)" : ""); + } + + // ==================== JSON 比对工具 ==================== + + /** 从 sessionBodyJson(整个 session 对象)里取出 chat_model_config 子对象(期望投影)。 */ + private JsonNode extractChatModelConfig(String sessionBodyJson) { + JsonNode session = parseOrNull(sessionBodyJson); + return session == null ? null : session.get("chat_model_config"); + } + + /** 比对两个 JSON 对象(拍平成 prefix.点路径 逐项比对),产出漂移项。expected/actual 任一为 null 时按整体缺失/多余处置。 */ + private List diffNode(String route, String prefix, JsonNode expected, JsonNode actual) { + Map flatE = flattenPrefixed(expected, prefix); + Map flatA = flattenPrefixed(actual, prefix); + return diffFlat(route, flatE, flatA); + } + + /** 两份「点路径→值」扁平 Map 的对称比对:期望缺失/多余/值不符各成一项漂移。键序稳定(并集有序)。 */ + private List diffFlat(String route, Map expected, Map actual) { + List out = new ArrayList<>(); + TreeSet keys = new TreeSet<>(); + keys.addAll(expected.keySet()); + keys.addAll(actual.keySet()); + for (String key : keys) { + boolean inE = expected.containsKey(key); + boolean inA = actual.containsKey(key); + if (inE && inA) { + if (!valuesEqual(expected.get(key), actual.get(key))) { + out.add(item(route, key, text(expected.get(key)), text(actual.get(key)))); + } + } else if (inE) { + // 期望有、投影无 = 投影缺失该键 + out.add(item(route, key, text(expected.get(key)), MISSING)); + } else { + // 投影有、激活版无 = 多余/陈旧键(绕过治理写入或旧值残留) + out.add(item(route, key, UNEXPECTED, text(actual.get(key)))); + } + } + return out; + } + + /** 把一段 JSON 拍平成「点路径→叶子值」有序 Map(null → 空 Map)。顶层键含点(如 area.key)按单键叶子处理、不再拆分。 */ + private Map flatten(JsonNode node) { + return flattenPrefixed(node, ""); + } + + private Map flattenPrefixed(JsonNode node, String prefix) { + Map flat = new LinkedHashMap<>(); + if (node == null || node.isNull()) { + return flat; + } + flattenInto(node, prefix, flat); + return flat; + } + + private void flattenInto(JsonNode node, String prefix, Map out) { + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry e = fields.next(); + String path = prefix.isEmpty() ? e.getKey() : prefix + "." + e.getKey(); + flattenInto(e.getValue(), path, out); + } + } else if (node.isArray()) { + for (int i = 0; i < node.size(); i++) { + flattenInto(node.get(i), prefix + "[" + i + "]", out); + } + } else { + out.put(prefix.isEmpty() ? "" : prefix, node); + } + } + + /** 值相等:都为数值时按数值比(吸收 10 vs 10.0),否则按文本比。 */ + private static boolean valuesEqual(JsonNode a, JsonNode b) { + if (a == null || b == null) { + return a == b; + } + if (a.isNumber() && b.isNumber()) { + return a.asDouble() == b.asDouble(); + } + return text(a).equals(text(b)); + } + + // ==================== 小工具 ==================== + + private static RouteReconcileVO newRoute(String route) { + RouteReconcileVO r = new RouteReconcileVO(); + r.setRoute(route); + r.setDriftItems(new ArrayList<>()); + return r; + } + + private static DriftItemVO item(String route, String key, String expected, String actual) { + DriftItemVO d = new DriftItemVO(); + d.setRoute(route); + d.setKey(key); + d.setExpected(expected); + d.setActual(actual); + return d; + } + + /** 节点转可读文本(标量取字面值;对象/数组取紧凑 JSON;null → "null")。 */ + private static String text(JsonNode node) { + if (node == null || node.isNull()) { + return "null"; + } + return node.isValueNode() ? node.asText() : node.toString(); + } + + /** 大字段摘要:超阈值以 len + sha256 前缀表示(避免报告塞进整段 prompt),否则原样。 */ + private static String summarize(String s) { + String v = nz(s); + if (v.length() <= SUMMARIZE_OVER) { + return v; + } + int bytes = v.getBytes(StandardCharsets.UTF_8).length; + return "len=" + v.length() + " bytes=" + bytes + " sha256=" + sha256Prefix(v); + } + + private JsonNode parseOrNull(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return JSON.readTree(json); + } catch (Exception e) { + // 投影内容非合法 JSON(Nacos dataId 被人为写坏等):防御性返回 null,交上层按缺失/差异处置、不抛穿 + log.warn("[gen-config-reconcile] 投影内容非合法 JSON len={}", json.length()); + return null; + } + } + + private static String sha256Prefix(String s) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(16); + for (int i = 0; i < 8 && i < digest.length; i++) { + sb.append(Character.forDigit((digest[i] >> 4) & 0xF, 16)); + sb.append(Character.forDigit(digest[i] & 0xF, 16)); + } + return sb.toString(); + } catch (Exception e) { + return "?"; + } + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static String nz(String s) { + return s == null ? "" : s; + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/NacosConfigPublishClient.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/NacosConfigPublishClient.java index 0aadb63d..f55d87f8 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/NacosConfigPublishClient.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/NacosConfigPublishClient.java @@ -139,6 +139,40 @@ public class NacosConfigPublishClient { } } + /** + * 只读探测生效 dataId 当前内容(漂移对账路 B:与当前激活版应有 {@code area.key} 逐项比对,设计 §3.6)。三态区分不可达/缺失/在—— + * 与 {@link #readFromDataId}(e2e 用、把「dataId 不存在」与「读失败」都返 null)不同:漂移对账必须把这两者分开,否则会把 + * Nacos 不可达(可用性问题)误判成投影缺失/漂移。本方法用登录成败作可达性探针:登录失败/异常 = 不可达(不算漂移); + * 登录成功但 GET 非2xx/空 = dataId 缺失(可达、投影缺失);有内容 = 投影在。 + * + * @param dataId 目标生效 dataId(生产按档 {@code gen-hot-params-{tier}};对账传 {@link #resolveDataId}) + * @return 三态探测结果 + */ + public GenConfigProbeResult probeDataId(String dataId) { + String token; + try { + token = login(); + } catch (Exception e) { + log.warn("[gen-config-reconcile][路B] Nacos 登录失败(判不可达、非漂移) dataId={} err={}", dataId, e.getMessage()); + return GenConfigProbeResult.unreachable(); + } + try { + String url = configsUrl() + "?accessToken=" + enc(token) + + "&dataId=" + enc(dataId) + "&group=" + enc(props.getNacosGroup()) + tenantParam(); + GenConfigHttpClient.HttpResult resp = http.exchange("GET", url, Map.of(), null, props.getNacosTimeoutSeconds()); + if (resp.ok() && StringUtils.hasText(resp.body())) { + return GenConfigProbeResult.found(resp.body()); + } + // 登录成功但 dataId 不存在/空(Nacos 对不存在的 dataId 返 404/空体)= 投影缺失(可达、非不可达) + log.info("[gen-config-reconcile][路B] dataId 缺失/空(可达、非漂移) dataId={} http={}", dataId, resp.statusCode()); + return GenConfigProbeResult.missing(); + } catch (Exception e) { + // 登录成功但读回途中网络异常 = 该次读不可达(不据此判漂移) + log.warn("[gen-config-reconcile][路B] 读回异常(判不可达) dataId={} err={}", dataId, e.getMessage()); + return GenConfigProbeResult.unreachable(); + } + } + /** * 删除指定 dataId(仅供路 B 真 e2e 清理演示 dataId;生产激活链不用)。 * diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigControllerTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigControllerTest.java index ebb0f457..53548bd9 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigControllerTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigControllerTest.java @@ -37,6 +37,9 @@ class AdminGenConfigControllerTest { assertPermission("activate", "aigc:config:activate", GenConfigActivateReqType()); assertPermission("rollback", "aigc:config:activate", GenConfigActivateReqType()); assertPermission("recoverActivating", "aigc:config:activate", Long.class); + // 步骤5:对账挂查询权限(只读诊断)、重推挂激活权限(写投影) + assertPermission("reconcile", "aigc:config:query", Long.class); + assertPermission("rebroadcast", "aigc:config:activate", Long.class); } private void assertPermission(String method, String expectedPermission, Class... paramTypes) throws Exception { @@ -68,13 +71,13 @@ class AdminGenConfigControllerTest { return Class.forName("com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigActivateReqVO"); } - /** 受权限门保护的端点数 = 12(4 写 + 5 读 + 3 激活),确保端点齐全(防漏挂)。 */ + /** 受权限门保护的端点数 = 14(4 写 + 6 读[含对账] + 4 激活[含重推]),确保端点齐全(防漏挂)。 */ @Test void testEndpointCount() { long endpoints = java.util.Arrays.stream(AdminGenConfigController.class.getDeclaredMethods()) .filter(m -> m.isAnnotationPresent(PreAuthorize.class)) .count(); - assertEquals(12, endpoints, "应有 12 个受权限门保护的端点(4 写 + 5 读 + 3 激活)"); + assertEquals(14, endpoints, "应有 14 个受权限门保护的端点(4 写 + 6 读[含对账] + 4 激活[含重推])"); } } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/AgentServicePatchClientTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/AgentServicePatchClientTest.java index 389a380e..1664a1b5 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/AgentServicePatchClientTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/AgentServicePatchClientTest.java @@ -122,4 +122,95 @@ class AgentServicePatchClientTest { assertTrue(r.detail().contains("IOException")); } + // ==================== 只读投影探测(步骤5 漂移对账;对真实 AgentScope 列表响应结构)==================== + + /** 读 agent system_prompt:GET /agent/ 列表(无单 GET)、按 id 过滤取 data.system_prompt;带 X-User-ID。 */ + @Test + void testProbeAgentSystemPrompt_found_shape() { + // 真实 GET /agent/ 响应形态:{agents:[{id, data:{system_prompt,...}}], total} + StubExchange stub = new StubExchange().respond(200, + "{\"agents\":[{\"id\":\"a1\",\"user_id\":\"system\",\"data\":{\"name\":\"cheap\",\"system_prompt\":\"你是设计师\"}}],\"total\":1}"); + AgentServicePatchClient client = new AgentServicePatchClient(props(), stub); + + GenConfigProbeResult r = client.probeAgentSystemPrompt("cheap", "a1"); + + assertTrue(r.reachable()); + assertTrue(r.found()); + assertEquals("你是设计师", r.value()); + // URL 与鉴权头 + Call g = stub.calls.get(0); + assertEquals("GET", g.method()); + assertEquals("http://lab:18300/agent/", g.url()); + assertEquals("system", g.headers().get("X-User-ID")); + } + + /** agent 列表里无该 id → 投影缺失(reachable 但 not found)。 */ + @Test + void testProbeAgentSystemPrompt_missing() { + StubExchange stub = new StubExchange().respond(200, + "{\"agents\":[{\"id\":\"other\",\"data\":{\"system_prompt\":\"x\"}}],\"total\":1}"); + AgentServicePatchClient client = new AgentServicePatchClient(props(), stub); + GenConfigProbeResult r = client.probeAgentSystemPrompt("cheap", "a1"); + assertTrue(r.reachable()); + assertFalse(r.found()); + } + + /** GET 通道异常 → 不可达(非漂移)。 */ + @Test + void testProbeAgentSystemPrompt_unreachable() { + GenConfigHttpClient.HttpExchange throwing = (m, u, h, b, t) -> { + throw new java.io.IOException("connection refused"); + }; + AgentServicePatchClient client = new AgentServicePatchClient(props(), throwing); + GenConfigProbeResult r = client.probeAgentSystemPrompt("cheap", "a1"); + assertFalse(r.reachable()); + } + + /** GET 非2xx → 不可达。 */ + @Test + void testProbeAgentSystemPrompt_non2xx_unreachable() { + StubExchange stub = new StubExchange().respond(500, "err"); + AgentServicePatchClient client = new AgentServicePatchClient(props(), stub); + assertFalse(client.probeAgentSystemPrompt("cheap", "a1").reachable()); + } + + /** 读 session chat_model_config:GET /sessions/?agent_id= 列表、按 session.id 过滤取 session.config.chat_model_config。 */ + @Test + void testProbeSessionModelConfig_found_shape() { + // 真实 GET /sessions/ 响应形态:{sessions:[{session:{id, config:{chat_model_config:{...}}}, is_running}], total} + StubExchange stub = new StubExchange().respond(200, + "{\"sessions\":[{\"session\":{\"id\":\"s1\",\"agent_id\":\"a1\",\"config\":{\"workspace_id\":\"w\"," + + "\"chat_model_config\":{\"type\":\"anthropic_credential\",\"credential_id\":\"c1\",\"model\":\"MiniMax-M3\",\"parameters\":{\"max_tokens\":16000}}}}" + + ",\"is_running\":false}],\"total\":1}"); + AgentServicePatchClient client = new AgentServicePatchClient(props(), stub); + + GenConfigProbeResult r = client.probeSessionModelConfig("cheap", "a1", "s1"); + + assertTrue(r.found()); + assertTrue(r.value().contains("\"model\":\"MiniMax-M3\"")); + Call g = stub.calls.get(0); + assertEquals("GET", g.method()); + assertEquals("http://lab:18300/sessions/?agent_id=a1", g.url(), "须复数 /sessions/ 且带 agent_id query"); + } + + /** session 列表里无该 session → 投影缺失。 */ + @Test + void testProbeSessionModelConfig_missing() { + StubExchange stub = new StubExchange().respond(200, + "{\"sessions\":[{\"session\":{\"id\":\"other\",\"config\":{\"chat_model_config\":{\"model\":\"m\"}}}}],\"total\":1}"); + AgentServicePatchClient client = new AgentServicePatchClient(props(), stub); + assertFalse(client.probeSessionModelConfig("cheap", "a1", "s1").found()); + } + + /** session 在但未配模型(chat_model_config=null) → 投影缺失。 */ + @Test + void testProbeSessionModelConfig_noModel_missing() { + StubExchange stub = new StubExchange().respond(200, + "{\"sessions\":[{\"session\":{\"id\":\"s1\",\"config\":{\"workspace_id\":\"w\",\"chat_model_config\":null}}}],\"total\":1}"); + AgentServicePatchClient client = new AgentServicePatchClient(props(), stub); + GenConfigProbeResult r = client.probeSessionModelConfig("cheap", "a1", "s1"); + assertTrue(r.reachable()); + assertFalse(r.found()); + } + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImplTest.java index 3142af60..f52acfa5 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImplTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImplTest.java @@ -11,6 +11,8 @@ import org.mockito.Mock; import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_ACTIVATE_DISPATCH_FAILED; import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_ACTIVATE_SANITY_FAILED; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_REBROADCAST_FAILED; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_REBROADCAST_NO_ACTIVE; import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_TIER_INVALID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -291,6 +293,81 @@ class GenConfigActivationServiceImplTest extends BaseMockitoUnitTest { verify(genConfigService, never()).abortActivating(any(), any()); // 不复位(恢复是向前执行到底) } + // ==================== 一键重推(rebroadcast,步骤5) ==================== + + /** 重推成功:ACTIVE 配置集按当前激活版双路重推,全成功 → 不动账本(不 begin/mark/abort),指针不变。 */ + @Test + void testRebroadcast_success_noLedgerWrite() { + stubSet(1L, AigcConfigStatusEnum.ACTIVE, 100L, "cheap"); + stubVersion(1L, 100L, "sys", NEW_CONTENT); + when(agentClient.patch(anyString(), any())).thenReturn(GenConfigDispatchResult.succeeded()); + when(nacosClient.publish(anyString(), any())).thenReturn(GenConfigDispatchResult.succeeded()); + + service.rebroadcastActive(1L); + + verify(agentClient).patch(eq("cheap"), any()); + verify(nacosClient).publish(eq("cheap"), any()); + // 重推只推投影、不写账本:三个账本原语都不调 + verify(genConfigService, never()).beginActivating(any(), any()); + verify(genConfigService, never()).markVersionActivated(any(), any()); + verify(genConfigService, never()).abortActivating(any(), any()); + } + + /** 重推幂等:对同一激活版重复重推两次,均只推投影、无账本副作用(patch/publish 各 2 次)。 */ + @Test + void testRebroadcast_idempotent() { + stubSet(1L, AigcConfigStatusEnum.ACTIVE, 100L, "tier2"); + stubVersion(1L, 100L, "sys", NEW_CONTENT); + when(agentClient.patch(anyString(), any())).thenReturn(GenConfigDispatchResult.succeeded()); + when(nacosClient.publish(anyString(), any())).thenReturn(GenConfigDispatchResult.succeeded()); + + service.rebroadcastActive(1L); + service.rebroadcastActive(1L); + + verify(agentClient, times(2)).patch(eq("tier2"), any()); + verify(nacosClient, times(2)).publish(eq("tier2"), any()); + verify(genConfigService, never()).markVersionActivated(any(), any()); + } + + /** 重推无落点:配置集无当前激活版 → 拒(AIGC_CONFIG_REBROADCAST_NO_ACTIVE),不读版本、不下发。 */ + @Test + void testRebroadcast_noActiveVersion_reject() { + stubSet(1L, AigcConfigStatusEnum.PENDING_REVIEW, null, "cheap"); + + ServiceException ex = assertThrows(ServiceException.class, () -> service.rebroadcastActive(1L)); + assertEquals(AIGC_CONFIG_REBROADCAST_NO_ACTIVE.getCode(), ex.getCode()); + verify(genConfigService, never()).getVersion(any(), any()); + verify(agentClient, never()).patch(anyString(), any()); + } + + /** 重推被拒:配置集正 ACTIVATING(有在途激活)→ 拒重推(请先 recover),不下发。 */ + @Test + void testRebroadcast_rejectWhenActivating() { + GenConfigSetDO set = stubSet(1L, AigcConfigStatusEnum.ACTIVATING, 100L, "cheap"); + set.setActivatingVersionId(101L); + + ServiceException ex = assertThrows(ServiceException.class, () -> service.rebroadcastActive(1L)); + assertEquals(AIGC_CONFIG_REBROADCAST_FAILED.getCode(), ex.getCode()); + verify(agentClient, never()).patch(anyString(), any()); + } + + /** 重推某路失败(路B publish 败) → REBROADCAST_FAILED、可重试;不补偿、不动账本(区别于 activate 失败路)。 */ + @Test + void testRebroadcast_pathFails_noCompensationNoLedger() { + stubSet(1L, AigcConfigStatusEnum.ACTIVE, 100L, "cheap"); + stubVersion(1L, 100L, "sys", NEW_CONTENT); + when(agentClient.patch(anyString(), any())).thenReturn(GenConfigDispatchResult.succeeded()); + when(nacosClient.publish(anyString(), any())).thenReturn(GenConfigDispatchResult.failed("publish_500")); + + ServiceException ex = assertThrows(ServiceException.class, () -> service.rebroadcastActive(1L)); + assertEquals(AIGC_CONFIG_REBROADCAST_FAILED.getCode(), ex.getCode()); + // 不补偿(patch/publish 各只发一次,无重推回退)、不动账本 + verify(agentClient, times(1)).patch(eq("cheap"), any()); + verify(nacosClient, times(1)).publish(eq("cheap"), any()); + verify(genConfigService, never()).abortActivating(any(), any()); + verify(genConfigService, never()).markVersionActivated(any(), any()); + } + // ==================== 夹具 ==================== private GenConfigSetDO stubSet(Long id, AigcConfigStatusEnum status, Long activeVersionId, String tier) { diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileRebroadcastRealIT.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileRebroadcastRealIT.java new file mode 100644 index 00000000..75e82ad4 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileRebroadcastRealIT.java @@ -0,0 +1,168 @@ +package com.wanxiang.huijing.game.module.aigc.service.config; + +import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigDriftReportRespVO; +import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigSetDO; +import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigVersionDO; +import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.util.StringUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 步骤5「路 B 漂移对账 + 一键重推」真 Nacos 集成证据——用生产客户端连 mini-infra 真 Nacos({@code 100.64.0.8:8848}·Tailscale + * 直连·NO_PROXY 绕系统 fake-ip 代理),对演示专用 dataId {@code gen-hot-params-e2e-recon-cheap} 走全链: + * 重推正确值 → 对账绿 → 手工篡改 → 对账检出漂移 → 一键重推 → 轮询窗内对账收敛回绿 → 清理删除,坐实设计 §3.6 对账/重推兜底。 + * + *

严禁碰生产 dataId(gen-hot-params-cheap / gen-hot-params-tier2)——用 nacosDataIdTemplate 指向演示 dataId、测完删除。 + * 治理账本侧(配置集/激活版)用 Mockito 桩(本 IT 只验路 B 真通道,不连 MySQL);当前激活版设为「路 B only(cheap 硬闸 10.0)」, + * 故路 A 不参与(无 routeA、不打 Service)。 + * + *

凭据:Nacos 口令从 {@code -Dnacos.password=} 或 env {@code NACOS_PASSWORD} 读(权威副本 docs/内网凭据与端点.md)。 + * 默认跳过:{@code @EnabledIfSystemProperty(aigc.config.e2e=1)}。出证据: + * {@code mvn test -Daigc.config.e2e=1 -Dnacos.password=<读凭据档> -Dtest=GenConfigReconcileRebroadcastRealIT}。 + * + * @author 绘境AI + */ +@EnabledIfSystemProperty(named = "aigc.config.e2e", matches = "1") +class GenConfigReconcileRebroadcastRealIT { + + private static final long SET_ID = 1L; + private static final long ACTIVE_VERSION = 100L; + /** 演示 dataId 模板({tier} 替换后 = gen-hot-params-e2e-recon-cheap;绝不碰生产 gen-hot-params-cheap/tier2)。 */ + private static final String DEMO_TEMPLATE = "gen-hot-params-e2e-recon-{tier}"; + private static final String DEMO_DATA_ID = "gen-hot-params-e2e-recon-cheap"; + /** 当前激活版内容:路 B only(便宜档硬闸 10.0)——路 A 无、不打 Service。 */ + private static final String ACTIVE_CONTENT = "{\"routeB\":{\"budget.cheap_rmb_hard_limit\":10.0}}"; + private static final String TAMPERED = "{\"budget.cheap_rmb_hard_limit\":999.0}"; + + private AigcConfigActivationProperties props() { + AigcConfigActivationProperties p = new AigcConfigActivationProperties(); + p.setNacosServerUrl(System.getProperty("aigc.config.nacos", "http://100.64.0.8:8848")); + p.setNacosUsername("nacos"); + String pass = System.getProperty("nacos.password", System.getenv("NACOS_PASSWORD")); + p.setNacosPassword(pass == null ? "" : pass); + p.setNacosNamespace(""); // 公共空间空串(勿写 public) + p.setNacosGroup("DEFAULT_GROUP"); + p.setNacosDataIdTemplate(DEMO_TEMPLATE); // 演示 dataId(安全,测完删) + p.setReconcileNacosToleranceSeconds(6); // 容忍窗:吸收 publish→read 最终一致延迟 + p.setReconcileNacosPollMillis(500L); + return p; + } + + /** 桩治理账本:ACTIVE 配置集(tier=cheap, activeVersionId=100) + 路 B only 激活版。 */ + private GenConfigService fakeGovernance() { + GenConfigService gov = mock(GenConfigService.class); + GenConfigSetDO set = new GenConfigSetDO(); + set.setId(SET_ID); + set.setTier("cheap"); + set.setStatus(AigcConfigStatusEnum.ACTIVE.getStatus()); + set.setActiveVersionId(ACTIVE_VERSION); + when(gov.getConfigSet(SET_ID)).thenReturn(set); + GenConfigVersionDO v = new GenConfigVersionDO(); + v.setId(ACTIVE_VERSION); + v.setConfigSetId(SET_ID); + v.setPrompt(null); // 无 prompt(路 A 不参与) + v.setContentJson(ACTIVE_CONTENT); + when(gov.getVersion(SET_ID, ACTIVE_VERSION)).thenReturn(v); + return gov; + } + + /** + * 全链:重推正确值 10.0 → 对账绿 → 篡改 999 → 对账检出漂移 → 一键重推 → 窗内对账收敛回绿 → 清理。 + * 全走生产客户端、真 Nacos、演示 dataId。 + */ + @Test + void reconcileRebroadcast_realNacos_demoDataId() { + AigcConfigActivationProperties props = props(); + assertTrue(StringUtils.hasText(props.getNacosPassword()), + "须注入 NACOS_PASSWORD(-Dnacos.password 或 env,见凭据档)"); + + GenConfigHttpClient.HttpExchange http = GenConfigHttpClient.defaultExchange(); // NO_PROXY 内网直连 + NacosConfigPublishClient nacos = new NacosConfigPublishClient(props, http); + AgentServicePatchClient agent = new AgentServicePatchClient(props, http); // 路 A 不用(激活版无 routeA) + GenConfigContentCodec codec = new GenConfigContentCodec(props); + GenConfigService gov = fakeGovernance(); + GenConfigActivationServiceImpl activation = new GenConfigActivationServiceImpl(gov, codec, agent, nacos); + GenConfigReconcileServiceImpl reconcile = new GenConfigReconcileServiceImpl(gov, codec, agent, nacos, props); + + try { + // ① 一键重推 = publish 当前激活版正确值 10.0 到演示 dataId + activation.rebroadcastActive(SET_ID); + assertTrue(waitDataIdContains(nacos, "10", 15000), "重推后数秒内 dataId 应含 10"); + + // ② 对账 → 绿(IN_SYNC) + GenConfigDriftReportRespVO r1 = reconcile.reconcile(SET_ID); + System.out.println("[路B-e2e] ②重推后对账 verdict=" + r1.getVerdict() + " summary=" + r1.getSummary()); + assertEquals("IN_SYNC", r1.getVerdict(), "重推正确值后应对账绿"); + + // ③ 手工篡改演示 dataId(错值 999),等篡改传播(最终一致) + assertTrue(nacos.publishToDataId(DEMO_DATA_ID, TAMPERED).ok(), "篡改 publish 应成功"); + assertTrue(waitDataIdContains(nacos, "999", 15000), "篡改后数秒内 dataId 应含 999(传播)"); + + // ④ 对账 → 检出漂移(DRIFT,budget 期望 10.0 实际 999.0) + GenConfigDriftReportRespVO r2 = reconcile.reconcile(SET_ID); + System.out.println("[路B-e2e] ④篡改后对账 verdict=" + r2.getVerdict() + " 漂移项=" + + r2.getDriftItems().stream().map(d -> d.getKey() + "[期望" + d.getExpected() + "/实际" + d.getActual() + "]").toList()); + assertEquals("DRIFT", r2.getVerdict(), "篡改后应检出漂移"); + assertTrue(r2.getDriftItems().stream().anyMatch( + d -> "budget.cheap_rmb_hard_limit".equals(d.getKey()) && "999.0".equals(d.getActual())), + "应检出 budget.cheap_rmb_hard_limit 漂移到 999.0"); + + // ⑤ 一键重推把投影拉回当前激活版 10.0 + activation.rebroadcastActive(SET_ID); + System.out.println("[路B-e2e] ⑤已一键重推拉回当前激活版 10.0"); + + // ⑥ 对账 → 容忍窗内收敛回绿(IN_SYNC) + GenConfigDriftReportRespVO r3 = reconcile.reconcile(SET_ID); + System.out.println("[路B-e2e] ⑥重推收敛后对账 verdict=" + r3.getVerdict() + " summary=" + r3.getSummary()); + assertEquals("IN_SYNC", r3.getVerdict(), "重推后应收敛回绿"); + } finally { + // ⑦ 清理:删除演示 dataId(务必删,别污染) + boolean removed = nacos.removeDataId(DEMO_DATA_ID); + System.out.println("[路B-e2e] ⑦清理演示 dataId removed=" + removed); + } + // ⑧ 删后读回验空(轮询等删除生效) + NacosConfigPublishClient verifier = new NacosConfigPublishClient(props(), GenConfigHttpClient.defaultExchange()); + assertTrue(waitDataIdNull(verifier, 15000), "删除后数秒内读回应为空"); + System.out.println("[路B-e2e] ⑧删后读回验空 OK"); + } + + /** 轮询原始读(readFromDataId)直到内容含 substring;超时 false。 */ + private boolean waitDataIdContains(NacosConfigPublishClient client, String substring, long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + String content = client.readFromDataId(DEMO_DATA_ID); + if (content != null && content.contains(substring)) { + return true; + } + sleep(500); + } + return false; + } + + /** 轮询原始读直到为空(删除生效);超时 false。 */ + private boolean waitDataIdNull(NacosConfigPublishClient client, long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (client.readFromDataId(DEMO_DATA_ID) == null) { + return true; + } + sleep(500); + } + return false; + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImplTest.java new file mode 100644 index 00000000..0236afc7 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImplTest.java @@ -0,0 +1,308 @@ +package com.wanxiang.huijing.game.module.aigc.service.config; + +import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigDriftReportRespVO; +import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigDriftReportRespVO.DriftItemVO; +import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigSetDO; +import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigVersionDO; +import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum; +import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link GenConfigReconcileServiceImpl} 单元测试——把守步骤5 漂移对账的判定矩阵(设计 §3.6):无漂移全绿 / 路A 单字段漂移 / + * 路B 单键漂移 / 两路同时漂移 / 路A·路B 投影缺失 / ACTIVATING 中间态跳过不误报 / Service 不可达报不可达非漂移 / + * Nacos 不可达同理 / 无激活版无可对账 / 路B 最终一致容忍窗内收敛不误报。 + * + *

用真 {@link GenConfigContentCodec}(期望载荷真组装,与激活下发口径一致)+ 桩 {@link GenConfigService}/两路客户端(不走真网络/真库)。 + * 容忍窗默认置 0(漂移用例单读即判、不空转 8s);单列一个用例把容忍窗设正数验「窗内收敛不误报」。 + * + * @author 绘境AI + */ +class GenConfigReconcileServiceImplTest extends BaseMockitoUnitTest { + + @Mock + private GenConfigService genConfigService; + @Mock + private AgentServicePatchClient agentClient; + @Mock + private NacosConfigPublishClient nacosClient; + + private GenConfigReconcileServiceImpl service; + + /** 当前激活版内容:路A(agentId/sessionId/完整 chat_model_config) + 路B(cheap 硬闸 10.0)。 */ + private static final String ACTIVE_CONTENT = + "{\"routeA\":{\"agentId\":\"a1\",\"sessionId\":\"s1\",\"session\":{\"chat_model_config\":" + + "{\"type\":\"anthropic_credential\",\"credential_id\":\"c1\",\"model\":\"MiniMax-M3\"," + + "\"parameters\":{\"max_tokens\":16000,\"thinking_budget\":8000}}}}," + + "\"routeB\":{\"budget.cheap_rmb_hard_limit\":10.0}}"; + private static final String ACTIVE_PROMPT = "便宜档系统提示词 v1"; + /** 与激活版一致的 chat_model_config 投影(Service GET /sessions 返回的子对象形态)。 */ + private static final String CMC_IN_SYNC = + "{\"type\":\"anthropic_credential\",\"credential_id\":\"c1\",\"model\":\"MiniMax-M3\"," + + "\"parameters\":{\"max_tokens\":16000,\"thinking_budget\":8000}}"; + /** 模型被改成 glm-5.2 的漂移投影。 */ + private static final String CMC_MODEL_DRIFT = + "{\"type\":\"anthropic_credential\",\"credential_id\":\"c1\",\"model\":\"glm-5.2\"," + + "\"parameters\":{\"max_tokens\":16000,\"thinking_budget\":8000}}"; + private static final String DATAID = "gen-hot-params-cheap"; + private static final String PB_IN_SYNC = "{\"budget.cheap_rmb_hard_limit\":10.0}"; + private static final String PB_DRIFT = "{\"budget.cheap_rmb_hard_limit\":999.0}"; + + @BeforeEach + void setUp() { + AigcConfigActivationProperties props = new AigcConfigActivationProperties(); + props.setReconcileNacosToleranceSeconds(0); // 漂移用例单读即判,不空转容忍窗 + GenConfigContentCodec codec = new GenConfigContentCodec(props); + service = new GenConfigReconcileServiceImpl(genConfigService, codec, agentClient, nacosClient, props); + } + + // ==================== 无漂移 ==================== + + /** 两路投影都与激活版一致 → IN_SYNC、零漂移项。 */ + @Test + void testReconcile_inSync() { + stubActive(); + when(agentClient.probeAgentSystemPrompt("cheap", "a1")).thenReturn(GenConfigProbeResult.found(ACTIVE_PROMPT)); + when(agentClient.probeSessionModelConfig("cheap", "a1", "s1")).thenReturn(GenConfigProbeResult.found(CMC_IN_SYNC)); + when(nacosClient.probeDataId(DATAID)).thenReturn(GenConfigProbeResult.found(PB_IN_SYNC)); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("IN_SYNC", report.getVerdict()); + assertTrue(report.getDriftItems().isEmpty(), "应无漂移项"); + assertEquals("一致", report.getPathA().getNote()); + assertEquals("一致", report.getPathB().getNote()); + } + + // ==================== 单路漂移 ==================== + + /** 路A 单字段漂移(模型 MiniMax-M3→glm-5.2)→ DRIFT,漂移项 key=chat_model_config.model。 */ + @Test + void testReconcile_pathA_modelDrift() { + stubActive(); + when(agentClient.probeAgentSystemPrompt("cheap", "a1")).thenReturn(GenConfigProbeResult.found(ACTIVE_PROMPT)); + when(agentClient.probeSessionModelConfig("cheap", "a1", "s1")).thenReturn(GenConfigProbeResult.found(CMC_MODEL_DRIFT)); + when(nacosClient.probeDataId(DATAID)).thenReturn(GenConfigProbeResult.found(PB_IN_SYNC)); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("DRIFT", report.getVerdict()); + assertEquals(1, report.getDriftItems().size()); + DriftItemVO d = report.getDriftItems().get(0); + assertEquals("A", d.getRoute()); + assertEquals("chat_model_config.model", d.getKey()); + assertEquals("MiniMax-M3", d.getExpected()); + assertEquals("glm-5.2", d.getActual()); + } + + /** 路B 单键漂移(硬闸 10.0→999.0)→ DRIFT,漂移项 key=budget.cheap_rmb_hard_limit、10.0 vs 999.0。 */ + @Test + void testReconcile_pathB_budgetDrift() { + stubActive(); + pathAInSync(); + when(nacosClient.probeDataId(DATAID)).thenReturn(GenConfigProbeResult.found(PB_DRIFT)); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("DRIFT", report.getVerdict()); + assertEquals(1, report.getDriftItems().size()); + DriftItemVO d = report.getDriftItems().get(0); + assertEquals("B", d.getRoute()); + assertEquals("budget.cheap_rmb_hard_limit", d.getKey()); + assertEquals("10.0", d.getExpected()); + assertEquals("999.0", d.getActual()); + } + + /** 两路同时漂移 → DRIFT,两项(A model + B budget)。 */ + @Test + void testReconcile_bothDrift() { + stubActive(); + when(agentClient.probeAgentSystemPrompt("cheap", "a1")).thenReturn(GenConfigProbeResult.found(ACTIVE_PROMPT)); + when(agentClient.probeSessionModelConfig("cheap", "a1", "s1")).thenReturn(GenConfigProbeResult.found(CMC_MODEL_DRIFT)); + when(nacosClient.probeDataId(DATAID)).thenReturn(GenConfigProbeResult.found(PB_DRIFT)); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("DRIFT", report.getVerdict()); + assertEquals(2, report.getDriftItems().size()); + assertTrue(report.getDriftItems().stream().anyMatch(d -> "A".equals(d.getRoute()) && "chat_model_config.model".equals(d.getKey()))); + assertTrue(report.getDriftItems().stream().anyMatch(d -> "B".equals(d.getRoute()) && "budget.cheap_rmb_hard_limit".equals(d.getKey()))); + } + + // ==================== 投影缺失 ==================== + + /** 路A agent 投影缺失(Service 未装配该 agent)→ DRIFT,system_prompt actual=(缺失),note 含「投影缺失」。 */ + @Test + void testReconcile_pathA_projectionMissing() { + stubActive(); + when(agentClient.probeAgentSystemPrompt("cheap", "a1")).thenReturn(GenConfigProbeResult.missing()); + when(agentClient.probeSessionModelConfig("cheap", "a1", "s1")).thenReturn(GenConfigProbeResult.found(CMC_IN_SYNC)); + when(nacosClient.probeDataId(DATAID)).thenReturn(GenConfigProbeResult.found(PB_IN_SYNC)); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("DRIFT", report.getVerdict()); + assertTrue(report.getDriftItems().stream().anyMatch( + d -> "system_prompt".equals(d.getKey()) && "(缺失)".equals(d.getActual()))); + assertTrue(report.getPathA().getNote().contains("投影缺失"), "note 应标注投影缺失:" + report.getPathA().getNote()); + } + + /** 路B dataId 为空(投影缺失)→ DRIFT,budget 键 actual=(缺失)。 */ + @Test + void testReconcile_pathB_projectionMissing() { + stubActive(); + pathAInSync(); + when(nacosClient.probeDataId(DATAID)).thenReturn(GenConfigProbeResult.missing()); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("DRIFT", report.getVerdict()); + assertTrue(report.getDriftItems().stream().anyMatch( + d -> "budget.cheap_rmb_hard_limit".equals(d.getKey()) && "(缺失)".equals(d.getActual()))); + assertEquals(Boolean.FALSE, report.getPathB().getProjectionFound()); + } + + // ==================== 非漂移态:中间态 / 不可达 / 无激活版 ==================== + + /** ACTIVATING 激活中间态 → ACTIVATING_SKIPPED,跳过比对、不读任何投影(不误报)。 */ + @Test + void testReconcile_activatingSkipped() { + GenConfigSetDO set = new GenConfigSetDO(); + set.setId(1L); + set.setTier("cheap"); + set.setStatus(AigcConfigStatusEnum.ACTIVATING.getStatus()); + set.setActiveVersionId(100L); + set.setActivatingVersionId(101L); + when(genConfigService.getConfigSet(1L)).thenReturn(set); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("ACTIVATING_SKIPPED", report.getVerdict()); + verify(agentClient, never()).probeAgentSystemPrompt(anyString(), anyString()); + verify(nacosClient, never()).probeDataId(anyString()); + verify(genConfigService, never()).getVersion(any(), any()); + } + + /** Service 不可达(路A agent 探测不可达)+ 路B 一致 → UNREACHABLE(非漂移),路A note=不可达、零漂移项。 */ + @Test + void testReconcile_serviceUnreachable() { + stubActive(); + when(agentClient.probeAgentSystemPrompt("cheap", "a1")).thenReturn(GenConfigProbeResult.unreachable()); + when(agentClient.probeSessionModelConfig("cheap", "a1", "s1")).thenReturn(GenConfigProbeResult.found(CMC_IN_SYNC)); + when(nacosClient.probeDataId(DATAID)).thenReturn(GenConfigProbeResult.found(PB_IN_SYNC)); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("UNREACHABLE", report.getVerdict()); + assertTrue(report.getDriftItems().isEmpty(), "不可达不产漂移项"); + assertTrue(report.getPathA().getNote().contains("不可达"), "路A note 应含不可达:" + report.getPathA().getNote()); + assertEquals(Boolean.FALSE, report.getPathA().getReachable()); + } + + /** Nacos 不可达 + 路A 一致 → UNREACHABLE(非漂移),路B note=不可达。 */ + @Test + void testReconcile_nacosUnreachable() { + stubActive(); + pathAInSync(); + when(nacosClient.probeDataId(DATAID)).thenReturn(GenConfigProbeResult.unreachable()); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("UNREACHABLE", report.getVerdict()); + assertTrue(report.getDriftItems().isEmpty()); + assertTrue(report.getPathB().getNote().contains("不可达"), "路B note 应含不可达:" + report.getPathB().getNote()); + } + + /** 有真漂移(路B) + 另一路(Service)不可达 → 仍裁 DRIFT(漂移优先),路A 明细如实标不可达。 */ + @Test + void testReconcile_driftTakesPriorityOverUnreachable() { + stubActive(); + when(agentClient.probeAgentSystemPrompt("cheap", "a1")).thenReturn(GenConfigProbeResult.unreachable()); + when(agentClient.probeSessionModelConfig("cheap", "a1", "s1")).thenReturn(GenConfigProbeResult.unreachable()); + when(nacosClient.probeDataId(DATAID)).thenReturn(GenConfigProbeResult.found(PB_DRIFT)); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("DRIFT", report.getVerdict()); + assertEquals(Boolean.FALSE, report.getPathA().getReachable()); + assertEquals(1, report.getDriftItems().size()); + } + + /** 无当前激活版 → NO_ACTIVE_VERSION,不读投影。 */ + @Test + void testReconcile_noActiveVersion() { + GenConfigSetDO set = new GenConfigSetDO(); + set.setId(1L); + set.setTier("cheap"); + set.setStatus(AigcConfigStatusEnum.PENDING_REVIEW.getStatus()); + set.setActiveVersionId(null); + when(genConfigService.getConfigSet(1L)).thenReturn(set); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("NO_ACTIVE_VERSION", report.getVerdict()); + verify(nacosClient, never()).probeDataId(anyString()); + } + + // ==================== 最终一致容忍窗 ==================== + + /** 路B 首读旧值(漂移)、窗内再读已收敛 → 容忍窗吸收传播延迟、IN_SYNC 不误报。 */ + @Test + void testReconcile_pathB_toleranceWindow_converges() { + // 用正数容忍窗 + 短轮询:首读旧值(999)、次读新值(10) → 应判一致 + AigcConfigActivationProperties props = new AigcConfigActivationProperties(); + props.setReconcileNacosToleranceSeconds(3); + props.setReconcileNacosPollMillis(50L); + GenConfigReconcileServiceImpl svc = new GenConfigReconcileServiceImpl( + genConfigService, new GenConfigContentCodec(props), agentClient, nacosClient, props); + stubActive(); // 内含 resolveDataId 桩 + pathAInSync(); + when(nacosClient.probeDataId(DATAID)) + .thenReturn(GenConfigProbeResult.found(PB_DRIFT)) // 首读:最终一致滞后的旧值 + .thenReturn(GenConfigProbeResult.found(PB_IN_SYNC)); // 窗内再读:已收敛 + + GenConfigDriftReportRespVO report = svc.reconcile(1L); + + assertEquals("IN_SYNC", report.getVerdict(), "窗内收敛应判一致、不把传播延迟误报为漂移"); + assertTrue(report.getDriftItems().isEmpty()); + } + + // ==================== 夹具 ==================== + + /** 桩一个 ACTIVE 配置集(tier=cheap, activeVersionId=100) + 版本100(ACTIVE_PROMPT/ACTIVE_CONTENT)。 */ + private void stubActive() { + GenConfigSetDO set = new GenConfigSetDO(); + set.setId(1L); + set.setTier("cheap"); + set.setStatus(AigcConfigStatusEnum.ACTIVE.getStatus()); + set.setActiveVersionId(100L); + when(genConfigService.getConfigSet(1L)).thenReturn(set); + GenConfigVersionDO v = new GenConfigVersionDO(); + v.setId(100L); + v.setConfigSetId(1L); + v.setPrompt(ACTIVE_PROMPT); + v.setContentJson(ACTIVE_CONTENT); + when(genConfigService.getVersion(1L, 100L)).thenReturn(v); + when(nacosClient.resolveDataId("cheap")).thenReturn(DATAID); // 仅有激活版(会走到路B)的用例才需要 + } + + /** 路A 两处投影都与激活版一致。 */ + private void pathAInSync() { + when(agentClient.probeAgentSystemPrompt("cheap", "a1")).thenReturn(GenConfigProbeResult.found(ACTIVE_PROMPT)); + when(agentClient.probeSessionModelConfig("cheap", "a1", "s1")).thenReturn(GenConfigProbeResult.found(CMC_IN_SYNC)); + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/NacosConfigPublishClientTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/NacosConfigPublishClientTest.java index 79780111..90899d0d 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/NacosConfigPublishClientTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/NacosConfigPublishClientTest.java @@ -143,4 +143,51 @@ class NacosConfigPublishClientTest { assertEquals("DELETE", stub.calls.get(1).method()); } + // ==================== 只读探测 probeDataId(步骤5 漂移对账;三态区分不可达/缺失/在)==================== + + /** 登录成功 + GET 返内容 → 投影在(found)。 */ + @Test + void testProbeDataId_found() { + StubExchange stub = new StubExchange() + .respond(200, "{\"accessToken\":\"TOK\"}") + .respond(200, "{\"budget.cheap_rmb_hard_limit\":10.0}"); + NacosConfigPublishClient client = new NacosConfigPublishClient(props(), stub); + GenConfigProbeResult r = client.probeDataId("gen-hot-params-cheap"); + assertTrue(r.reachable()); + assertTrue(r.found()); + assertEquals("{\"budget.cheap_rmb_hard_limit\":10.0}", r.value()); + } + + /** 登录成功 + GET 404/空 → 可达但投影缺失(missing,非不可达)。 */ + @Test + void testProbeDataId_missing() { + StubExchange stub = new StubExchange() + .respond(200, "{\"accessToken\":\"TOK\"}") + .respond(404, "config data not exist"); + NacosConfigPublishClient client = new NacosConfigPublishClient(props(), stub); + GenConfigProbeResult r = client.probeDataId("gen-hot-params-cheap"); + assertTrue(r.reachable(), "登录成功即可达"); + assertFalse(r.found(), "dataId 不存在 = 投影缺失"); + } + + /** 登录失败(401) → 不可达(可用性问题,非漂移)。 */ + @Test + void testProbeDataId_loginFail_unreachable() { + StubExchange stub = new StubExchange().respond(401, "unauthorized"); + NacosConfigPublishClient client = new NacosConfigPublishClient(props(), stub); + GenConfigProbeResult r = client.probeDataId("gen-hot-params-cheap"); + assertFalse(r.reachable(), "登录失败判不可达"); + } + + /** 空口令 → 登录前置失败 → 不可达(不静默)。 */ + @Test + void testProbeDataId_emptyPassword_unreachable() { + AigcConfigActivationProperties p = props(); + p.setNacosPassword(""); + StubExchange stub = new StubExchange(); + NacosConfigPublishClient client = new NacosConfigPublishClient(p, stub); + assertFalse(client.probeDataId("gen-hot-params-cheap").reachable()); + assertEquals(0, stub.calls.size(), "空口令前置判不可达,不发任何 HTTP"); + } + }