merge: W-CFG-D 漂移对账+一键重推·步骤5(三态严判 ACTIVATING_SKIPPED/UNREACHABLE 不误报/最终一致容忍窗8s/重推复用dispatch幂等指针不动/不加表;真e2e 篡改→检出→重推→收敛→清理全链;fable 终审:hash/父链/18文件零越界/全模块仅两既存债/e2e带凭据亲跑绿;携带高价值发现=C波路A /session 应为复数,随即另commit修正)
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-04 11:26:25 -07:00
commit a8dd5e500e
18 changed files with 1569 additions and 2 deletions

View File

@ -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, "配置重推下发失败(当前激活版指针不变、可重试):{}");
}

View File

@ -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<GenConfigDriftReportRespVO> 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<Boolean> rebroadcast(@RequestParam("configSetId") Long configSetId) {
genConfigActivationService.rebroadcastActive(configSetId);
return success(true);
}
}

View File

@ -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)
*
* <p>对账 = 拉「生效投影」(Service 的 agent/session 路 A、Nacos 生效 dataId 路 B) 与「当前激活版」应有值逐项比对,产出结构化漂移报告
* (设计 §3.6)。核心是<b>严格区分三种「非漂移态」不误报</b>:① 配置集正 ACTIVATING → 激活中间态、跳过比对;② Service/Nacos
* 不可达 → 可用性问题、非漂移;③ 路 B 读用轮询容忍窗吸收 Nacos publish→read 的最终一致延迟(改值后立即读可能返旧值)。
*
* <p><b>{@link #verdict} 五态</b>(顶层裁定,取值见字段说明):{@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<DriftItemVO> 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<DriftItemVO> 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;
}
}

View File

@ -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)。
*
* <p><b>为何走列表端点</b>:AgentScope 2.0.2 的 agent 路由<b>没有「单个 GET /agent/&#123;id&#125;」</b>(只有 {@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)。
*
* <p>同理 AgentScope 2.0.2 <b>无「单个 GET /sessions/&#123;id&#125;」</b>(只有 {@code GET /sessions/?agent_id=} 列表、
* {@code /sessions/&#123;id&#125;/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<String, String> 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<String, String> headers, String body) {
long start = System.currentTimeMillis();

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -59,4 +59,20 @@ public interface GenConfigActivationService {
*/
void recoverActivating(Long configSetId);
/**
* 一键重推:按<b>当前激活版</b>重放双路下发,把已漂移的生效投影拉回当前激活版(配置控制面阶段二 · 步骤5 漂移兜底)。
*
* <p>与 {@link #activate} 的关键区别是<b>不动账本</b>:重推的目标就是当前激活版本身,当前激活版指针已经是它、保持不动,
* 不落 ACTIVATING、不前移指针、不写激活审计——重推只把「投影」重新推成「账本已认定的当前激活版」,是投影向账本的收敛,
* 不是一次新的激活。故也<b>无需补偿</b>:重推推的就是权威内容,某路失败只是这一路没收敛(另一路已收敛回激活版),
* 判可重试即可,绝不会把投影推离激活版(设计 §3.6:对账检出漂移后一键重推把投影拉回激活版)。
*
* <p><b>幂等</b>:同一激活版重复重推无副作用(PATCH/publish 都是同值覆盖)。<b>前置</b>:配置集须有当前激活版
* (否则 AIGC_CONFIG_REBROADCAST_NO_ACTIVE);正处 ACTIVATING(有在途激活)时拒绝重推(请先 recover,避免与在途激活相互踩)。
*
* @param configSetId 配置集 ID
* @throws com.wanxiang.huijing.framework.common.exception.ServiceException 无当前激活版 / 正激活中 / 某路下发失败(可重试)
*/
void rebroadcastActive(Long configSetId);
}

View File

@ -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());
}
// ==================== 内部:双路下发 + 补偿 ====================
/**

View File

@ -0,0 +1,38 @@
package com.wanxiang.huijing.game.module.aigc.service.config;
/**
* 投影探测结果(配置控制面阶段二 · 步骤5 漂移对账):读一处生效投影(Service 的 agent/session、Nacos 生效 dataId)时的三态结果。
*
* <p>漂移对账要严格区分「真漂移」「投影缺失」「系统不可达」三种情形(设计 §3.6),三者对应不同处置:不可达是可用性问题不算漂移、
* 投影缺失是显式后续增强位、只有「可达且投影在但值不符」才是真漂移。故读投影不能只返回一个「值 or null」(那会把不可达与缺失
* 混为一谈),而要三态:
* <ul>
* <li><b>不可达</b>({@code reachable=false}):HTTP 超时/连接拒绝/登录失败等——系统当前联系不上,无从判断投影,<b>不算漂移</b>;</li>
* <li><b>可达但投影缺失</b>({@code reachable=true, found=false}):系统在线但目标不存在(Service 没装配该 agent/session、
* Nacos 该 dataId 为空)——投影缺失(Service 未自愈的显式后续增强位,设计 §6);</li>
* <li><b>可达且投影在</b>({@code reachable=true, found=true}):拿到投影值 {@link #value},交对账逐项比对当前激活版应有值。</li>
* </ul>
*
* @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);
}
}

View File

@ -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 失败兜底 + 漂移对账)
*
* <p>「配置中心为权威、Service 与 worker 为投影」这个定位需要一道兜底闸:投影可能与当前激活版漂移——Service 重启若 Redis 未持久化
* 会丢配置、有人绕过治理直接改了 Redis/Nacos、某次激活部分失败没补偿干净、或机器上遗留 env 压过了本该生效的激活值(设计 §3.6)。
* 本 Service 提供只读对账:拉路 A(Service 的 agent system_prompt + session chat_model_config)与路 B(Nacos 生效 dataId 的
* 预算/门阈值 area.key)当前投影,与当前激活版应有值逐项比对,产出结构化漂移报告。
*
* <p><b>严格区分三种「非漂移态」不误报</b>(这是本步最核心的正确性要求):
* <ul>
* <li><b>激活中间态</b>:配置集处 ACTIVATING(有在途激活)→ 报 {@code ACTIVATING_SKIPPED}、跳过比对,绝不把激活在途误判成漂移;</li>
* <li><b>不可达</b>:Service/Nacos 联系不上 → 该路报不可达(可用性问题,非漂移),对账<b>不抛异常</b>、把不可达作数据返回;</li>
* <li><b>最终一致延迟</b>:路 B 读用轮询容忍窗吸收 Nacos publish→read 的传播滞后(C 波真发现:改值后立即读可能返旧值),
* 窗内比对一致即早退、窗满仍不一致才判真漂移。</li>
* </ul>
* 对账本身是「漂移的检测面」而非「失败的第一道防线」——第一道是激活链路的即时补偿(步骤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);
}

View File

@ -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)
*
* <p>不改任何状态的只读诊断:取当前激活版内容 → 经 {@link GenConfigContentCodec} 组装出「与激活时下发口径一致」的期望载荷 →
* 拉路 A(Service 的 agent/session 投影)与路 B(Nacos 生效 dataId 投影)→ 逐项比对 → 产出结构化漂移报告。三种非漂移态
* (激活中间态 / 不可达 / 最终一致延迟)严格分开,避免误报(见接口 Javadoc 与设计 §3.6)。
*
* <p>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<String, JsonNode> 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<DriftItemVO> 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<String, JsonNode> 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<DriftItemVO> 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<DriftItemVO> diffNode(String route, String prefix, JsonNode expected, JsonNode actual) {
Map<String, JsonNode> flatE = flattenPrefixed(expected, prefix);
Map<String, JsonNode> flatA = flattenPrefixed(actual, prefix);
return diffFlat(route, flatE, flatA);
}
/** 两份「点路径→值」扁平 Map 的对称比对:期望缺失/多余/值不符各成一项漂移。键序稳定(并集有序)。 */
private List<DriftItemVO> diffFlat(String route, Map<String, JsonNode> expected, Map<String, JsonNode> actual) {
List<DriftItemVO> out = new ArrayList<>();
TreeSet<String> 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<String, JsonNode> flatten(JsonNode node) {
return flattenPrefixed(node, "");
}
private Map<String, JsonNode> flattenPrefixed(JsonNode node, String prefix) {
Map<String, JsonNode> flat = new LinkedHashMap<>();
if (node == null || node.isNull()) {
return flat;
}
flattenInto(node, prefix, flat);
return flat;
}
private void flattenInto(JsonNode node, String prefix, Map<String, JsonNode> out) {
if (node.isObject()) {
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> 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() ? "<root>" : 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;
}
}

View File

@ -139,6 +139,40 @@ public class NacosConfigPublishClient {
}
}
/**
* 只读探测生效 dataId 当前内容(漂移对账路 B:与当前激活版应有 {@code area.key} 逐项比对,设计 §3.6)。<b>三态</b>区分不可达/缺失/在——
* 与 {@link #readFromDataId}(e2e 用、把「dataId 不存在」与「读失败」都返 null)不同:漂移对账必须把这两者分开,否则会把
* Nacos 不可达(可用性问题)误判成投影缺失/漂移。本方法用<b>登录成败作可达性探针</b>:登录失败/异常 = 不可达(不算漂移);
* 登录成功但 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;生产激活链不用)。
*

View File

@ -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 激活[含重推])");
}
}

View File

@ -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());
}
}

View File

@ -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) {

View File

@ -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 代理),对<b>演示专用 dataId</b> {@code gen-hot-params-e2e-recon-cheap} 走全链:
* 重推正确值 → 对账绿 → 手工篡改 → 对账检出漂移 → 一键重推 → 轮询窗内对账收敛回绿 → 清理删除,坐实设计 §3.6 对账/重推兜底。
*
* <p><b>严禁碰生产 dataId</b>(gen-hot-params-cheap / gen-hot-params-tier2)——用 nacosDataIdTemplate 指向演示 dataId、测完删除。
* 治理账本侧(配置集/激活版)用 Mockito 桩(本 IT 只验路 B 真通道,不连 MySQL);当前激活版设为「路 B only(cheap 硬闸 10.0)」,
* 故路 A 不参与(无 routeA、不打 Service)。
*
* <p><b>凭据</b>:Nacos 口令从 {@code -Dnacos.password=} 或 env {@code NACOS_PASSWORD} 读(权威副本 docs/内网凭据与端点.md)。
* <b>默认跳过</b>:{@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();
}
}
}

View File

@ -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 最终一致容忍窗内收敛不误报。
*
* <p>用真 {@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));
}
}

View File

@ -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");
}
}