diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigStatusEnum.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigStatusEnum.java
new file mode 100644
index 00000000..8db6250d
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigStatusEnum.java
@@ -0,0 +1,100 @@
+package com.wanxiang.huijing.game.module.aigc.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * 生成配置集生命周期状态枚举(配置控制面阶段二 · 设计 §3.2)
+ *
+ *
治理单元是「配置集」,一个配置集走这样一条生命周期:
+ *
+ * [创建] ──────────────────────────────▶ 草稿(DRAFT)
+ * 草稿 ──定版(freezeVersion,版本行落库)──▶ 待审(PENDING_REVIEW)
+ * 待审 ──激活(审核通过,MVP 创始人直接激活)─▶ 已激活(ACTIVE)
+ * 已激活 ──编辑出新版本再定版────────────────▶ 待审 (旧版继续服务,active_version_id 不动)
+ * 已激活 ──回滚(取历史版本行重放激活)──────────▶ 已激活 (指针前移到历史版)
+ * 待审 ──驳回────────────────────────────▶ 草稿
+ * {草稿/待审/已激活} ──归档──────────────────▶ 已归档(ARCHIVED)
+ *
+ *
+ * 几条关键语义:① 激活之前 Nacos/Service 完全不被触碰——草稿与版本行都只是治理库内数据,
+ * 到「激活」内容才第一次离开治理库(堵死「草稿提前生效」);② 编辑(改草稿)本身不改状态,
+ * 只有定版(→待审)与激活(→已激活)改状态;③ 当前激活版指针只由激活动作前移,定版不动它。
+ *
+ *
双路激活的下发编排(PATCH /agent+/session、publish Nacos)属阶段二步骤3,本状态机只承载
+ * 治理侧的账本流转(状态位 + 指针前移 + 审计);步骤3 的下发成功后调账本收口方法把 status 转 ACTIVE。
+ *
+ * @author 绘境AI
+ */
+@Getter
+@AllArgsConstructor
+public enum AigcConfigStatusEnum {
+
+ /** 草稿:创始人在 yudao 编辑一组旋钮,尚未定版(此配置集本轮编辑还没有对应版本行) */
+ DRAFT(0, "草稿"),
+ /** 待审:已定版(版本行落库,creator/时间随行),等待激活/审核(MVP 创始人直接激活、放量后走 BPM) */
+ PENDING_REVIEW(10, "待审"),
+ /** 已激活:当前激活版指针指向某版本行,其内容已(由步骤3)双路下发生效 */
+ ACTIVE(20, "已激活"),
+ /** 已归档:整个配置集退场(被更新配置取代或人工归档),只读、不再流转 */
+ ARCHIVED(30, "已归档");
+
+ /** 状态值(落库 tinyint) */
+ private final Integer status;
+ /** 状态名(展示用) */
+ private final String name;
+
+ /**
+ * 根据状态值查枚举
+ *
+ * @param status 状态值
+ * @return 枚举;非法值返回 null
+ */
+ public static AigcConfigStatusEnum of(Integer status) {
+ return Arrays.stream(values())
+ .filter(e -> Objects.equals(e.status, status))
+ .findFirst().orElse(null);
+ }
+
+ /**
+ * 判断从当前状态到目标状态是否为合法流转(状态机唯一裁决处,服务层据此拒非法流转)。
+ *
+ *
合法边(对齐类注释状态图):
+ *
+ * - DRAFT → PENDING_REVIEW(定版)
+ * - PENDING_REVIEW → PENDING_REVIEW(编辑后再定版,新待审版覆盖旧待审版)/ ACTIVE(激活)/ DRAFT(驳回)
+ * - ACTIVE → PENDING_REVIEW(编辑出新版本再定版)/ ACTIVE(回滚重放激活)
+ * - {DRAFT, PENDING_REVIEW, ACTIVE} → ARCHIVED(归档)
+ *
+ * 归档态(ARCHIVED)为终态,不允许再流出。
+ *
+ * @param from 当前状态(null 视为非法)
+ * @param to 目标状态(null 视为非法)
+ * @return true=合法流转
+ */
+ public static boolean canTransit(AigcConfigStatusEnum from, AigcConfigStatusEnum to) {
+ if (from == null || to == null) {
+ return false;
+ }
+ // 归档为终态:任何状态都不能从 ARCHIVED 再流出
+ if (from == ARCHIVED) {
+ return false;
+ }
+ // 归档入边:非归档态均可归档
+ if (to == ARCHIVED) {
+ return true;
+ }
+ return switch (from) {
+ case DRAFT -> to == PENDING_REVIEW;
+ // 待审可再定版(编辑后新待审版覆盖旧待审版,自环)、激活、或驳回回草稿
+ case PENDING_REVIEW -> to == PENDING_REVIEW || to == ACTIVE || to == DRAFT;
+ // 已激活可再定版(编辑出新版本→待审)或回滚(重放激活,仍为已激活)
+ case ACTIVE -> to == PENDING_REVIEW || to == ACTIVE;
+ default -> false;
+ };
+ }
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigTierEnum.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigTierEnum.java
new file mode 100644
index 00000000..f632ef61
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigTierEnum.java
@@ -0,0 +1,55 @@
+package com.wanxiang.huijing.game.module.aigc.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * 生成配置集档位枚举(配置控制面阶段二 · 设计 §3.2/§3.3)
+ *
+ * 档位是配置集的分组维度,决定这一组旋钮映射到哪一档的生效通道——尤其是路 B(预算/门阈值)在 Nacos 上
+ * 的按档隔离 dataId 与 key 命名:cheap-worker 用 {@code cheap_rmb_hard_limit}、tier2 gen-worker 用
+ * {@code rmb_hard_limit},两档各接一个 NacosHotConfig、各读各的 dataId(设计 §3.3 路 B 按档隔离)。
+ * 激活哪一档就只 publish 那一档的 dataId(阶段二步骤3)。
+ *
+ * @author 绘境AI
+ */
+@Getter
+@AllArgsConstructor
+public enum AigcConfigTierEnum {
+
+ /** 便宜档:AI 轻参与的高质轻量游戏(cheap-worker,MiniMax-M3 路,软停线 cheap_rmb_hard_limit=¥10) */
+ CHEAP("cheap", "便宜档"),
+ /** 富游戏档:AI 深参与的自治富游戏(tier2 gen-worker,Phaser,软停线 rmb_hard_limit=¥50) */
+ TIER2("tier2", "富游戏档");
+
+ /** 档位码(落库 tier 列) */
+ private final String tier;
+ /** 档位名(展示用) */
+ private final String name;
+
+ /**
+ * 校验档位码是否合法
+ *
+ * @param tier 档位码
+ * @return true=合法档位码
+ */
+ public static boolean isValid(String tier) {
+ return of(tier) != null;
+ }
+
+ /**
+ * 根据档位码查枚举
+ *
+ * @param tier 档位码
+ * @return 枚举;非法值返回 null
+ */
+ public static AigcConfigTierEnum of(String tier) {
+ return Arrays.stream(values())
+ .filter(e -> Objects.equals(e.tier, tier))
+ .findFirst().orElse(null);
+ }
+
+}
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 c51c0297..f9a0876b 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
@@ -64,4 +64,25 @@ public interface ErrorCodeConstants {
/** 回调参数非法:status 取值非 succeeded/failed,或 failed 分支 failureReason 不在契约#6 七值枚举内 */
ErrorCode AIGC_CALLBACK_STATUS_INVALID = new ErrorCode(1_101_003_002, "回调 status 取值非法");
+ // ========== 生成配置治理 1-101-004-***(配置控制面阶段二 · yudao 配置中心 步骤1+2)==========
+ // 口径:生成配置集的版本化治理子域(改→定版→审核挂点→激活→回滚),错误码续编 aigc 004 子段、不跳段。
+ /** 配置集不存在:configSetId 不在 game_aigc_config_set */
+ ErrorCode AIGC_CONFIG_SET_NOT_EXISTS = new ErrorCode(1_101_004_000, "生成配置集不存在");
+ /** 配置集业务键重复:set_key 已被占用(uk_set_key 保证唯一) */
+ ErrorCode AIGC_CONFIG_SET_KEY_DUPLICATE = new ErrorCode(1_101_004_001, "配置集业务键已存在");
+ /** 配置版本不存在:versionId 不在 game_aigc_config_version,或不属于指定配置集 */
+ ErrorCode AIGC_CONFIG_VERSION_NOT_EXISTS = new ErrorCode(1_101_004_002, "配置版本不存在");
+ /** 草稿内容为空:prompt 与 content_json 均空,不可定版(定版须有可快照的内容) */
+ ErrorCode AIGC_CONFIG_DRAFT_EMPTY = new ErrorCode(1_101_004_003, "配置草稿内容为空,无法定版");
+ /** 状态机非法流转:不满足 AigcConfigStatusEnum#canTransit(如归档态再流出、越过定版直接激活) */
+ ErrorCode AIGC_CONFIG_STATUS_ILLEGAL = new ErrorCode(1_101_004_004, "配置集状态非法流转");
+ /** 配置内容非合法 JSON:content_json 无法解析(定版前置校验,避免脏内容落版本行) */
+ ErrorCode AIGC_CONFIG_CONTENT_INVALID_JSON = new ErrorCode(1_101_004_005, "配置内容不是合法的 JSON");
+ /** 配置集档位非法:tier 不在 {cheap, tier2} */
+ ErrorCode AIGC_CONFIG_TIER_INVALID = new ErrorCode(1_101_004_006, "配置集档位非法(仅 cheap/tier2)");
+ /** 配置集已归档:归档态只读,拒绝编辑/定版/激活 */
+ ErrorCode AIGC_CONFIG_SET_ARCHIVED = new ErrorCode(1_101_004_007, "配置集已归档,不可再编辑");
+ /** 版本号并发冲突:uk_set_version 撞键(同一配置集同一版本号被并发定版占用;重试即可) */
+ ErrorCode AIGC_CONFIG_VERSION_CONFLICT = new ErrorCode(1_101_004_008, "配置版本号冲突,请重试");
+
}
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
new file mode 100644
index 00000000..73181b10
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigController.java
@@ -0,0 +1,144 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.config;
+
+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;
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetRespVO;
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetSaveDraftReqVO;
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigVersionDiffRespVO;
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigVersionRespVO;
+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.GenConfigService;
+import com.wanxiang.huijing.framework.common.pojo.CommonResult;
+import com.wanxiang.huijing.framework.common.pojo.PageResult;
+import com.wanxiang.huijing.framework.common.util.object.BeanUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
+
+/**
+ * 管理后台(game-admin)- 生成配置治理控制器(配置控制面阶段二 · yudao 配置中心 步骤1+2)
+ *
+ *
端前缀 /admin-api 由框架按包名 controller.admin.* 自动添加。鉴权:RBAC,@PreAuthorize + 网关双重校验;
+ * creator 由 yudao 登录态自动填充、归因到人。能力面(步骤1+2)= 建/改配置集、定版、历史列表、任一历史版取回、两版 diff。
+ *
+ *
激活端点不在本波:双路激活(值域校验 + PATCH /agent+/session + publish Nacos + 账本收口)属阶段二步骤3,
+ * 由步骤3 另开激活端点(权限 {@code aigc:config:activate},本波预留不暴露);账本收口逻辑已在
+ * {@link GenConfigService#markVersionActivated} 就位,步骤3 在双路下发成功后调它。
+ *
+ * @author 绘境AI
+ */
+@Slf4j
+@Tag(name = "管理后台 - 生成配置治理")
+@RestController
+@RequestMapping("/aigc/config")
+@Validated
+public class AdminGenConfigController {
+
+ @Resource
+ private GenConfigService genConfigService;
+
+ // ==================== 配置集:建/改/查/归档 ====================
+
+ @PostMapping("/create")
+ @Operation(summary = "新建配置集", description = "落草稿态;档位 cheap/tier2,业务键唯一,creator 归因到人")
+ @PreAuthorize("@ss.hasPermission('aigc:config:save')")
+ public CommonResult createConfigSet(@Valid @RequestBody GenConfigSetCreateReqVO reqVO) {
+ return success(genConfigService.createConfigSet(reqVO));
+ }
+
+ @PutMapping("/update-draft")
+ @Operation(summary = "改配置集草稿", description = "编辑草稿工作副本;不改状态、不动激活指针;已归档拒绝编辑")
+ @PreAuthorize("@ss.hasPermission('aigc:config:save')")
+ public CommonResult updateDraft(@Valid @RequestBody GenConfigSetSaveDraftReqVO reqVO) {
+ genConfigService.saveDraft(reqVO);
+ return success(true);
+ }
+
+ @PostMapping("/archive")
+ @Operation(summary = "归档配置集", description = "整集退场转已归档、只读")
+ @PreAuthorize("@ss.hasPermission('aigc:config:archive')")
+ public CommonResult archiveConfigSet(@RequestParam("id") Long id) {
+ genConfigService.archiveConfigSet(id);
+ return success(true);
+ }
+
+ @GetMapping("/get")
+ @Operation(summary = "取单个配置集", description = "含草稿 + 当前激活版指针 + 状态")
+ @Parameter(name = "id", description = "配置集 ID", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('aigc:config:query')")
+ public CommonResult getConfigSet(@RequestParam("id") Long id) {
+ GenConfigSetDO set = genConfigService.getConfigSet(id);
+ return success(BeanUtils.toBean(set, GenConfigSetRespVO.class));
+ }
+
+ @GetMapping("/page")
+ @Operation(summary = "配置集分页", description = "按档位/状态/业务键筛选")
+ @PreAuthorize("@ss.hasPermission('aigc:config:query')")
+ public CommonResult> getConfigSetPage(@Valid GenConfigSetPageReqVO pageReqVO) {
+ PageResult pageResult = genConfigService.getConfigSetPage(pageReqVO);
+ return success(BeanUtils.toBean(pageResult, GenConfigSetRespVO.class));
+ }
+
+ // ==================== 版本行:历史列表 / 取回 / diff ====================
+
+ @GetMapping("/version/list")
+ @Operation(summary = "配置集历史版本列表", description = "按版本号倒序;摘要视图(不含大字段 prompt/contentJson)")
+ @Parameter(name = "configSetId", description = "配置集 ID", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('aigc:config:query')")
+ public CommonResult> listVersions(@RequestParam("configSetId") Long configSetId) {
+ List versions = genConfigService.listVersions(configSetId);
+ // 列表摘要:剥离大字段 prompt/contentJson,避免历史列表拖大响应(详情取回见 /version/get)
+ List list = versions.stream().map(v -> {
+ GenConfigVersionRespVO vo = BeanUtils.toBean(v, GenConfigVersionRespVO.class);
+ vo.setPrompt(null);
+ vo.setContentJson(null);
+ return vo;
+ }).collect(Collectors.toList());
+ return success(list);
+ }
+
+ @GetMapping("/version/get")
+ @Operation(summary = "取任一历史版本(全量内容)", description = "含全量 prompt/contentJson;校验归属该配置集")
+ @PreAuthorize("@ss.hasPermission('aigc:config:query')")
+ public CommonResult getVersion(@RequestParam("configSetId") Long configSetId,
+ @RequestParam("versionId") Long versionId) {
+ GenConfigVersionDO version = genConfigService.getVersion(configSetId, versionId);
+ return success(BeanUtils.toBean(version, GenConfigVersionRespVO.class));
+ }
+
+ @GetMapping("/version/diff")
+ @Operation(summary = "两版 diff", description = "prompt 给是否变更+两侧字节数;结构化旋钮拍平点路径逐项增/删/改")
+ @PreAuthorize("@ss.hasPermission('aigc:config:query')")
+ public CommonResult diffVersions(@RequestParam("configSetId") Long configSetId,
+ @RequestParam("versionAId") Long versionAId,
+ @RequestParam("versionBId") Long versionBId) {
+ return success(genConfigService.diffVersions(configSetId, versionAId, versionBId));
+ }
+
+ @PostMapping("/freeze")
+ @Operation(summary = "定版", description = "把当前草稿快照成不可变版本行(单库事务),状态转待审;不动激活指针")
+ @PreAuthorize("@ss.hasPermission('aigc:config:freeze')")
+ public CommonResult freezeVersion(@Valid @RequestBody GenConfigFreezeReqVO reqVO) {
+ return success(genConfigService.freezeVersion(reqVO));
+ }
+
+}
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/GenConfigFreezeReqVO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigFreezeReqVO.java
new file mode 100644
index 00000000..c774787c
--- /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/GenConfigFreezeReqVO.java
@@ -0,0 +1,28 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 定版 Request VO(admin 端)
+ *
+ * 定版(freezeVersion)把配置集当前草稿内容快照成不可变的版本行:分配下一个版本号、算 content_hash/content_bytes、
+ * 元数据 creator/时间随行、同库同事务一次写成,配置集状态转「待审」(设计 §3.1/§3.2)。定版不动当前激活版指针。
+ *
+ * @author 绘境AI
+ */
+@Schema(description = "管理后台 - 配置集定版 Request VO")
+@Data
+public class GenConfigFreezeReqVO {
+
+ @Schema(description = "配置集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "配置集 ID 不能为空")
+ private Long id;
+
+ @Schema(description = "本次定版变更说明(可空)", example = "抬 max_tokens 到 16000、软停线降到 ¥10")
+ @Size(max = 512, message = "变更说明长度不能超过 512")
+ private String changeNote;
+
+}
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/GenConfigSetCreateReqVO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigSetCreateReqVO.java
new file mode 100644
index 00000000..b0462126
--- /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/GenConfigSetCreateReqVO.java
@@ -0,0 +1,43 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 新建生成配置集 Request VO(admin 端)
+ *
+ *
创建即落草稿态(DRAFT):可只登记档位/业务键/名称,草稿内容(prompt/结构化旋钮)留空后续经「改草稿」补,
+ * 也可创建时就带上初始草稿内容。creator 由 yudao 登录态自动填充(不接受前端传入,归因到人)。
+ *
+ * @author 绘境AI
+ */
+@Schema(description = "管理后台 - 新建生成配置集 Request VO")
+@Data
+public class GenConfigSetCreateReqVO {
+
+ @Schema(description = "档位:cheap/tier2", requiredMode = Schema.RequiredMode.REQUIRED, example = "cheap")
+ @NotBlank(message = "档位不能为空")
+ private String tier;
+
+ @Schema(description = "配置集业务键(唯一)", requiredMode = Schema.RequiredMode.REQUIRED, example = "cheap-default")
+ @NotBlank(message = "配置集业务键不能为空")
+ @Size(max = 64, message = "配置集业务键长度不能超过 64")
+ private String setKey;
+
+ @Schema(description = "配置集显示名", example = "便宜档默认配置")
+ @Size(max = 128, message = "配置集显示名长度不能超过 128")
+ private String name;
+
+ @Schema(description = "配置集描述", example = "MiniMax-M3 便宜档默认 prompt/参数/预算/门阈值")
+ @Size(max = 512, message = "配置集描述长度不能超过 512")
+ private String description;
+
+ @Schema(description = "初始草稿 prompt 正文(可空,后续可经改草稿补)")
+ private String draftPrompt;
+
+ @Schema(description = "初始草稿结构化旋钮 JSON(可空;须为合法 JSON,含 model/参数/预算/门阈值 + 映射语义)")
+ private String draftContentJson;
+
+}
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/GenConfigSetPageReqVO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigSetPageReqVO.java
new file mode 100644
index 00000000..246f122b
--- /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/GenConfigSetPageReqVO.java
@@ -0,0 +1,27 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo;
+
+import com.wanxiang.huijing.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 生成配置集分页 Request VO(admin 端)
+ *
+ * @author 绘境AI
+ */
+@Schema(description = "管理后台 - 生成配置集分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class GenConfigSetPageReqVO extends PageParam {
+
+ @Schema(description = "按档位筛选:cheap/tier2", example = "cheap")
+ private String tier;
+
+ @Schema(description = "按生命周期状态筛选:0草稿 10待审 20已激活 30已归档", example = "20")
+ private Integer status;
+
+ @Schema(description = "按业务键模糊筛选", example = "cheap")
+ private String setKey;
+
+}
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/GenConfigSetRespVO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigSetRespVO.java
new file mode 100644
index 00000000..7ab82b36
--- /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/GenConfigSetRespVO.java
@@ -0,0 +1,55 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 生成配置集 Response VO(admin 端)
+ *
+ *
含配置集元数据 + 草稿工作副本 + 当前激活版指针 + 生命周期状态 + 审计字段(creator/时间)。
+ *
+ * @author 绘境AI
+ */
+@Schema(description = "管理后台 - 生成配置集 Response VO")
+@Data
+public class GenConfigSetRespVO {
+
+ @Schema(description = "配置集 ID", example = "1024")
+ private Long id;
+
+ @Schema(description = "档位:cheap/tier2", example = "cheap")
+ private String tier;
+
+ @Schema(description = "配置集业务键", example = "cheap-default")
+ private String setKey;
+
+ @Schema(description = "配置集显示名", example = "便宜档默认配置")
+ private String name;
+
+ @Schema(description = "配置集描述")
+ private String description;
+
+ @Schema(description = "生命周期状态:0草稿 10待审 20已激活 30已归档", example = "20")
+ private Integer status;
+
+ @Schema(description = "编辑中 prompt 工作副本")
+ private String draftPrompt;
+
+ @Schema(description = "编辑中结构化旋钮工作副本 JSON")
+ private String draftContentJson;
+
+ @Schema(description = "当前激活版指针(版本行 ID;null=尚无激活版)", example = "2048")
+ private Long activeVersionId;
+
+ @Schema(description = "创建者(归因到人)", example = "1")
+ private String creator;
+
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ @Schema(description = "最后更新时间")
+ private LocalDateTime updateTime;
+
+}
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/GenConfigSetSaveDraftReqVO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigSetSaveDraftReqVO.java
new file mode 100644
index 00000000..f8aa50de
--- /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/GenConfigSetSaveDraftReqVO.java
@@ -0,0 +1,39 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 改配置集草稿 Request VO(admin 端)
+ *
+ *
编辑配置集的草稿工作副本(prompt/结构化旋钮)与可选元数据(名称/描述)。编辑本身不改配置集状态——
+ * 只有定版(→待审)与激活(→已激活)改状态;已归档配置集拒绝编辑(设计 §3.2)。
+ * 草稿改动只写配置集行的 draft_* 列,不落版本行;定版才把当前草稿快照进版本行。
+ *
+ * @author 绘境AI
+ */
+@Schema(description = "管理后台 - 改配置集草稿 Request VO")
+@Data
+public class GenConfigSetSaveDraftReqVO {
+
+ @Schema(description = "配置集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "配置集 ID 不能为空")
+ private Long id;
+
+ @Schema(description = "配置集显示名(可空=不改)", example = "便宜档默认配置")
+ @Size(max = 128, message = "配置集显示名长度不能超过 128")
+ private String name;
+
+ @Schema(description = "配置集描述(可空=不改)")
+ @Size(max = 512, message = "配置集描述长度不能超过 512")
+ private String description;
+
+ @Schema(description = "草稿 prompt 正文(可空=不改)")
+ private String draftPrompt;
+
+ @Schema(description = "草稿结构化旋钮 JSON(可空=不改;非空须为合法 JSON)")
+ private String draftContentJson;
+
+}
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/GenConfigVersionDiffRespVO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigVersionDiffRespVO.java
new file mode 100644
index 00000000..6be19b1d
--- /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/GenConfigVersionDiffRespVO.java
@@ -0,0 +1,74 @@
+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;
+
+/**
+ * 两版配置 diff Response VO(admin 端,应用层 diff)
+ *
+ *
两版 diff 在应用层做(不在库内 diff,设计 §3.1):prompt 正文只给「是否变更 + 两侧字节数」(大字段不逐字符 diff),
+ * 结构化旋钮 content_json 解析成 JSON 树、拍平成点路径逐项比对,给出 增/删/改 的键路径与两侧值。
+ *
+ * @author 绘境AI
+ */
+@Schema(description = "管理后台 - 两版配置 diff Response VO")
+@Data
+public class GenConfigVersionDiffRespVO {
+
+ @Schema(description = "配置集 ID", example = "1024")
+ private Long configSetId;
+
+ @Schema(description = "版本 A 的版本行 ID", example = "2048")
+ private Long versionAId;
+
+ @Schema(description = "版本 A 的版本号", example = "2")
+ private Integer versionANo;
+
+ @Schema(description = "版本 B 的版本行 ID", example = "2049")
+ private Long versionBId;
+
+ @Schema(description = "版本 B 的版本号", example = "3")
+ private Integer versionBNo;
+
+ @Schema(description = "prompt 正文是否变更(按 content 精确比对)", example = "true")
+ private Boolean promptChanged;
+
+ @Schema(description = "版本 A 的 prompt utf8mb4 字节数", example = "30720")
+ private Long promptBytesA;
+
+ @Schema(description = "版本 B 的 prompt utf8mb4 字节数", example = "31000")
+ private Long promptBytesB;
+
+ @Schema(description = "版本 A 全量内容 sha256")
+ private String contentHashA;
+
+ @Schema(description = "版本 B 全量内容 sha256")
+ private String contentHashB;
+
+ @Schema(description = "结构化旋钮逐项变更列表(增/删/改)")
+ private List knobChanges;
+
+ /**
+ * 单个结构化旋钮的变更项(点路径寻址,如 session.parameters.maxTokens)
+ */
+ @Schema(description = "结构化旋钮变更项")
+ @Data
+ public static class KnobChange {
+
+ @Schema(description = "旋钮键路径(点分,如 session.parameters.maxTokens)", example = "session.parameters.maxTokens")
+ private String path;
+
+ @Schema(description = "变更类型:ADDED 新增 / REMOVED 删除 / CHANGED 改值", example = "CHANGED")
+ private String changeType;
+
+ @Schema(description = "版本 A 的值(字符串化;ADDED 时为 null)", example = "8000")
+ private String valueA;
+
+ @Schema(description = "版本 B 的值(字符串化;REMOVED 时为 null)", example = "16000")
+ private String valueB;
+
+ }
+
+}
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/GenConfigVersionRespVO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/vo/GenConfigVersionRespVO.java
new file mode 100644
index 00000000..1836fe8e
--- /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/GenConfigVersionRespVO.java
@@ -0,0 +1,50 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 生成配置版本行 Response VO(admin 端)
+ *
+ * 历史版本快照的全量内容:prompt 正文 + 结构化旋钮 JSON + content_hash/content_bytes + 变更说明 + 定版者/时间。
+ * 历史列表场景可只用摘要字段(不含大字段 prompt);取任一历史版详情时含全量 prompt/contentJson。
+ *
+ * @author 绘境AI
+ */
+@Schema(description = "管理后台 - 生成配置版本行 Response VO")
+@Data
+public class GenConfigVersionRespVO {
+
+ @Schema(description = "版本行 ID(= versionId)", example = "2048")
+ private Long id;
+
+ @Schema(description = "所属配置集 ID", example = "1024")
+ private Long configSetId;
+
+ @Schema(description = "配置集内版本号(1 起单调递增)", example = "3")
+ private Integer version;
+
+ @Schema(description = "prompt 正文快照(历史列表摘要视图可为空、详情视图含全量)")
+ private String prompt;
+
+ @Schema(description = "结构化旋钮全量快照 JSON")
+ private String contentJson;
+
+ @Schema(description = "全量内容 sha256", example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
+ private String contentHash;
+
+ @Schema(description = "全量内容 utf8mb4 字节数(按字节计)", example = "30720")
+ private Long contentBytes;
+
+ @Schema(description = "本次定版变更说明")
+ private String changeNote;
+
+ @Schema(description = "定版者(归因到人)", example = "1")
+ private String creator;
+
+ @Schema(description = "定版时间")
+ private LocalDateTime createTime;
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/dataobject/config/GenConfigSetDO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/dataobject/config/GenConfigSetDO.java
new file mode 100644
index 00000000..f8f1e45b
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/dataobject/config/GenConfigSetDO.java
@@ -0,0 +1,79 @@
+package com.wanxiang.huijing.game.module.aigc.dal.dataobject.config;
+
+import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum;
+import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigTierEnum;
+import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 生成配置集 DO(对应表 game_aigc_config_set,配置控制面阶段二治理单元)
+ *
+ *
治理单元是「配置集」而非单个旋钮:一款档位的生成行为由一组协同旋钮共同决定(prompt+模型+参数+预算+门阈值),
+ * 以配置集为激活单元,才能让一组本该一起变的旋钮一次激活、不出现「新 prompt 配旧模型」的半拉子状态(设计 §3.2)。
+ *
+ *
继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列,
+ * 故本类只声明业务字段。creator = 编辑者 yudao 登录用户,归因到人(设计目标:改动可审计、归因到人)。
+ *
+ *
草稿工作副本 draftPrompt/draftContentJson:编辑中的内容存在配置集行上(可反复改),定版
+ * ({@code freezeVersion})时快照进不可变的版本行 {@link GenConfigVersionDO}。编辑本身不改 {@link #status},
+ * 只有定版(→待审)与激活(→已激活)改状态。
+ *
+ *
activeVersionId(当前激活版指针):只由激活动作前移,定版不动它——把「编辑中/待审」与「已生效」
+ * 清晰分开。双路激活的下发编排(PATCH /agent+/session、publish Nacos)属阶段二步骤3,本 DO 只承载治理账本
+ * (状态位 + 指针),不含下发逻辑。
+ *
+ * @author 绘境AI
+ */
+@TableName("game_aigc_config_set")
+@KeySequence("game_aigc_config_set_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class GenConfigSetDO extends TenantBaseDO {
+
+ /**
+ * 配置集 ID(= 对外 configSetId)
+ */
+ private Long id;
+ /**
+ * 档位码(取值对齐 {@link AigcConfigTierEnum}:cheap/tier2)。
+ * 决定路 B(预算/门阈值)在 Nacos 上的按档隔离 dataId 与 key 命名(cheap 用 cheap_rmb_hard_limit、tier2 用 rmb_hard_limit)。
+ */
+ private String tier;
+ /**
+ * 配置集业务键(租户内唯一,如 cheap-default/tier2-default;uk_set_key 保证不重复)
+ */
+ private String setKey;
+ /**
+ * 配置集显示名
+ */
+ private String name;
+ /**
+ * 配置集描述
+ */
+ private String description;
+ /**
+ * 生命周期状态(取值对齐 {@link AigcConfigStatusEnum}:0草稿 10待审 20已激活 30已归档)。
+ * 状态机合法流转由 AigcConfigStatusEnum#canTransit 裁决、GenConfigService 强校验,DO 层只承载数据。
+ */
+ private Integer status;
+ /**
+ * 编辑中 prompt 工作副本(定版时快照进版本行;MEDIUMTEXT 容 few-shot 增长,不进 Nacos)。
+ * 可空——纯参数型配置集可只填 draftContentJson。
+ */
+ private String draftPrompt;
+ /**
+ * 编辑中结构化旋钮工作副本 JSON(model/参数/预算/门阈值 + 到 /agent+/session 字段与 Nacos key 的映射语义)。
+ * 定版前只做「合法 JSON」校验;字段名到 AgentScope 真实 schema 的对齐(消费 /agent/schema)属步骤3 激活编排职责,
+ * 本层不硬编码字段名(设计 §6:映射以 /agent/schema 为准、框架升版可能变)。
+ */
+ private String draftContentJson;
+ /**
+ * 当前激活版指针(→ game_aigc_config_version.id)。
+ * 只由激活动作前移,定版不动它;NULL=尚无激活版(配置集从未被激活过)。
+ */
+ private Long activeVersionId;
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/dataobject/config/GenConfigVersionDO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/dataobject/config/GenConfigVersionDO.java
new file mode 100644
index 00000000..6fc3ece2
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/dataobject/config/GenConfigVersionDO.java
@@ -0,0 +1,69 @@
+package com.wanxiang.huijing.game.module.aigc.dal.dataobject.config;
+
+import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 生成配置版本行 DO(对应表 game_aigc_config_version,配置集全量内容快照,只增不删 / append-only)
+ *
+ *
定版({@code freezeVersion})把配置集的草稿内容快照成不可变的一行:prompt 正文({@link #prompt},MEDIUMTEXT)
+ * + 结构化旋钮全量({@link #contentJson})+ 元数据(creator/时间随行)同库同事务一次写成(设计 §3.1 定版原子性)。
+ *
+ *
为何 prompt 落 MySQL MEDIUMTEXT 而非 Nacos / MinIO(设计 §3.1,创始人拍 B):① Nacos 单配置内容默认上限
+ * 100KB,prompt 加 few-shot 会向上限爬;② 定版要原子(内容在库外则定版成跨系统写);③ diff/按内容检索要就地;
+ * ④ MinIO 是「多文件源树」形状(成品源工程归档 seam),prompt 是「一段文本字段」形状,MinIO 的大对象/流式价值用不上。
+ * 列型取 MEDIUMTEXT(16MB)而非 TEXT(64KB):30KB 级 prompt 加 few-shot 有越过 64KB 的现实可能。
+ *
+ *
prompt 正文只做整行读写、不参与检索,故 DDL 不建全文/普通索引;另存 {@link #contentHash} 与 {@link #contentBytes}
+ * 两列供快速比对与容量核对。两版 diff 在应用层做(GenConfigService#diffVersions),不在库内 diff。
+ *
+ *
继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id。
+ * 版本行只增不删——{@code deleted} 逻辑删除列留位不使用;量级 = 人工定版频次(每天几次),多年也只几千行,
+ * 留归档策略位、不做提前优化。
+ *
+ * @author 绘境AI
+ */
+@TableName("game_aigc_config_version")
+@KeySequence("game_aigc_config_version_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class GenConfigVersionDO extends TenantBaseDO {
+
+ /**
+ * 版本行 ID(= 对外 versionId;配置集的 active_version_id 指向它)
+ */
+ private Long id;
+ /**
+ * 所属配置集 ID(→ game_aigc_config_set.id)
+ */
+ private Long configSetId;
+ /**
+ * 配置集内版本号(1 起单调递增;freezeVersion 取 max+1 分配,uk_set_version 兜底并发)。
+ * 普通业务序号列,非 MyBatis-Plus 乐观锁(未加 @Version)。
+ */
+ private Integer version;
+ /**
+ * prompt 正文快照(最大、改得最勤的旋钮;MEDIUMTEXT 16MB;只整行读写、不索引、不进 Nacos)。可空。
+ */
+ private String prompt;
+ /**
+ * 结构化旋钮全量快照 JSON(model/参数/预算/门阈值 + 到 /agent+/session 字段与 Nacos key 的映射语义)。可空。
+ */
+ private String contentJson;
+ /**
+ * 全量内容 sha256 十六进制(prompt+content_json 规范化拼接的摘要;快速比对/去重)
+ */
+ private String contentHash;
+ /**
+ * 全量内容 utf8mb4 字节数(按字节计、非字符数;含中文/emoji 时口径正确,供容量核对)
+ */
+ private Long contentBytes;
+ /**
+ * 本次定版变更说明(可空)
+ */
+ private String changeNote;
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/config/GenConfigSetMapper.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/config/GenConfigSetMapper.java
new file mode 100644
index 00000000..6c06918c
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/config/GenConfigSetMapper.java
@@ -0,0 +1,44 @@
+package com.wanxiang.huijing.game.module.aigc.dal.mysql.config;
+
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetPageReqVO;
+import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigSetDO;
+import com.wanxiang.huijing.framework.common.pojo.PageResult;
+import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
+import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 生成配置集 Mapper(配置控制面阶段二治理单元)
+ *
+ * @author 绘境AI
+ */
+@Mapper
+public interface GenConfigSetMapper extends BaseMapperX {
+
+ /**
+ * 按业务键取配置集(创建时查重、按键定位;uk_set_key 保证至多一条)。
+ * 租户由 yudao 多租户插件自动注入过滤。
+ *
+ * @param setKey 配置集业务键
+ * @return 配置集 DO;不存在返回 null
+ */
+ default GenConfigSetDO selectBySetKey(String setKey) {
+ return selectOne(new LambdaQueryWrapperX()
+ .eq(GenConfigSetDO::getSetKey, setKey));
+ }
+
+ /**
+ * 配置集分页(admin 端,按档位/状态/业务键筛选)
+ *
+ * @param reqVO 分页 + 可选 tier/status/setKey 筛选
+ * @return 分页结果(按 id 倒序,最新在前)
+ */
+ default PageResult selectPage(GenConfigSetPageReqVO reqVO) {
+ return selectPage(reqVO, new LambdaQueryWrapperX()
+ .eqIfPresent(GenConfigSetDO::getTier, reqVO.getTier())
+ .eqIfPresent(GenConfigSetDO::getStatus, reqVO.getStatus())
+ .likeIfPresent(GenConfigSetDO::getSetKey, reqVO.getSetKey())
+ .orderByDesc(GenConfigSetDO::getId));
+ }
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/config/GenConfigVersionMapper.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/config/GenConfigVersionMapper.java
new file mode 100644
index 00000000..203c3317
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/config/GenConfigVersionMapper.java
@@ -0,0 +1,61 @@
+package com.wanxiang.huijing.game.module.aigc.dal.mysql.config;
+
+import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigVersionDO;
+import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
+import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 生成配置版本行 Mapper(配置集全量内容快照,只增不删)
+ *
+ * 历史列表靠 {@link #selectListBySetId}(按版本号倒序,最新在前);下一个版本号靠 {@link #selectMaxVersion}
+ * (max+1 分配,uk_set_version 兜底并发);取任一历史版靠 {@link #selectByIdAndSetId}(校验归属该配置集)。
+ *
+ * @author 绘境AI
+ */
+@Mapper
+public interface GenConfigVersionMapper extends BaseMapperX {
+
+ /**
+ * 取配置集当前最大版本号(定版时 max+1 分配下一个版本号)。
+ *
+ * @param configSetId 配置集 ID
+ * @return 当前最大版本号;该配置集尚无版本行时返回 null
+ */
+ default Integer selectMaxVersion(Long configSetId) {
+ // orderByDesc + LIMIT 1 取最大版本号一行;LIMIT 1 保证 ≤1 行,无 selectOne 多行风险
+ List list = selectList(new LambdaQueryWrapperX()
+ .eq(GenConfigVersionDO::getConfigSetId, configSetId)
+ .orderByDesc(GenConfigVersionDO::getVersion)
+ .last("LIMIT 1"));
+ return list.isEmpty() ? null : list.get(0).getVersion();
+ }
+
+ /**
+ * 配置集的历史版本列表(按版本号倒序,最新在前)。
+ *
+ * @param configSetId 配置集 ID
+ * @return 历史版本行列表(含全量内容;无则空列表)
+ */
+ default List selectListBySetId(Long configSetId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(GenConfigVersionDO::getConfigSetId, configSetId)
+ .orderByDesc(GenConfigVersionDO::getVersion));
+ }
+
+ /**
+ * 按版本行 ID 取版本(并校验归属该配置集,防越权取他集版本)。
+ *
+ * @param id 版本行 ID
+ * @param configSetId 期望所属配置集 ID
+ * @return 版本行 DO;不存在或不属于该配置集返回 null
+ */
+ default GenConfigVersionDO selectByIdAndSetId(Long id, Long configSetId) {
+ return selectOne(new LambdaQueryWrapperX()
+ .eq(GenConfigVersionDO::getId, id)
+ .eq(GenConfigVersionDO::getConfigSetId, configSetId));
+ }
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigService.java
new file mode 100644
index 00000000..14f23b89
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigService.java
@@ -0,0 +1,130 @@
+package com.wanxiang.huijing.game.module.aigc.service.config;
+
+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;
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetSaveDraftReqVO;
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigVersionDiffRespVO;
+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.framework.common.pojo.PageResult;
+
+import java.util.List;
+
+/**
+ * 生成配置治理 Service(配置控制面阶段二 · yudao 配置中心 步骤1+2)
+ *
+ * 治理层把散落的生成配置旋钮升级为「可版本、可回滚、可归因、可审批的受治理资产」:以配置集为治理单元,
+ * 走「改 → 定版 → 审核挂点 → 激活 → 回滚」生命周期(设计 §3.2)。本 Service 承载步骤1+2 的能力面——
+ * 建/改配置集、定版、历史列表、任一历史版取回、两版 diff,以及激活的账本收口(状态位 + 指针前移)。
+ *
+ *
激活的双路下发编排(PATCH /agent+/session、publish Nacos)属阶段二步骤3,不在本 Service 落地。
+ * 本 Service 只提供激活的账本侧 {@link #markVersionActivated}(幂等地把状态转 ACTIVE、当前激活版指针前移),
+ * 步骤3 的激活编排在完成值域校验 + 双路下发之后调它收口账本——见该方法 Javadoc 的接缝说明。
+ *
+ * @author 绘境AI
+ */
+public interface GenConfigService {
+
+ // ==================== 步骤1:配置集建模 + 治理骨架 ====================
+
+ /**
+ * 新建配置集(落草稿态 DRAFT)。
+ *
+ * @param reqVO 档位/业务键/名称/描述 + 可选初始草稿内容
+ * @return 新建配置集 ID
+ */
+ Long createConfigSet(GenConfigSetCreateReqVO reqVO);
+
+ /**
+ * 改配置集草稿(编辑草稿工作副本;不改状态、不动激活指针)。已归档配置集拒绝编辑。
+ *
+ * @param reqVO 配置集 ID + 待改的名称/描述/草稿内容(null 字段=不改)
+ */
+ void saveDraft(GenConfigSetSaveDraftReqVO reqVO);
+
+ /**
+ * 取单个配置集(含草稿 + 当前激活版指针 + 状态)。
+ *
+ * @param id 配置集 ID
+ * @return 配置集 DO
+ * @throws com.wanxiang.huijing.framework.common.exception.ServiceException 不存在时抛 AIGC_CONFIG_SET_NOT_EXISTS
+ */
+ GenConfigSetDO getConfigSet(Long id);
+
+ /**
+ * 配置集分页(admin 端,按档位/状态/业务键筛选)。
+ *
+ * @param reqVO 分页 + 筛选
+ * @return 分页结果
+ */
+ PageResult getConfigSetPage(GenConfigSetPageReqVO reqVO);
+
+ /**
+ * 归档配置集(整集退场,转 ARCHIVED,只读)。
+ *
+ * @param id 配置集 ID
+ */
+ void archiveConfigSet(Long id);
+
+ // ==================== 步骤2:版本行与历史(MySQL) ====================
+
+ /**
+ * 定版:把配置集当前草稿内容快照成不可变的版本行(分配下一个版本号、算 content_hash/content_bytes、
+ * 元数据 creator/时间随行、同库同事务一次写成),配置集状态转「待审」。定版不动当前激活版指针。
+ *
+ * @param reqVO 配置集 ID + 变更说明
+ * @return 新落库的版本行 ID(= versionId)
+ */
+ Long freezeVersion(GenConfigFreezeReqVO reqVO);
+
+ /**
+ * 配置集的历史版本列表(按版本号倒序,最新在前;含全量内容 DO,列表视图由调用方裁剪大字段)。
+ *
+ * @param configSetId 配置集 ID
+ * @return 历史版本行列表
+ */
+ List listVersions(Long configSetId);
+
+ /**
+ * 取任一历史版本的全量内容(并校验归属该配置集)。
+ *
+ * @param configSetId 配置集 ID
+ * @param versionId 版本行 ID
+ * @return 版本行 DO(全量 prompt/contentJson)
+ * @throws com.wanxiang.huijing.framework.common.exception.ServiceException 不存在/不归属时抛 AIGC_CONFIG_VERSION_NOT_EXISTS
+ */
+ GenConfigVersionDO getVersion(Long configSetId, Long versionId);
+
+ /**
+ * 两版 diff(应用层做):prompt 给「是否变更 + 两侧字节数」,结构化旋钮拍平成点路径逐项比对给增/删/改。
+ *
+ * @param configSetId 配置集 ID
+ * @param versionAId 版本 A 的版本行 ID
+ * @param versionBId 版本 B 的版本行 ID
+ * @return diff 结果 VO
+ */
+ GenConfigVersionDiffRespVO diffVersions(Long configSetId, Long versionAId, Long versionBId);
+
+ // ==================== 激活账本收口(步骤3 接缝) ====================
+
+ /**
+ * 【步骤3 接缝 · 激活账本收口】幂等地把配置集状态转 ACTIVE、当前激活版指针前移到指定版本行。
+ *
+ * 这不是完整的激活动作。完整激活(阶段二步骤3)= 值域 sanity 校验 → 双路下发(PATCH /agent+/session
+ * 写 Service Redis、publish 预算/门阈值到按档隔离的 Nacos 生效 dataId)→ 下发全部成功后调用本方法把治理
+ * 账本收口。本方法只承载账本侧(MySQL 内的状态位 + 指针 + 审计),不含任何跨系统下发——那是步骤3 接管。
+ *
+ *
之所以在步骤2 就落这半:设计 §3.2 明确「当前激活版指针只由激活动作前移、定版不动它」,指针前移是治理账本
+ * 的数据行为、纯 MySQL;把它与双路下发解耦,步骤3 的激活编排只需在下发成功后调本方法,失败时不调(指针不前移、
+ * 保留上一激活版为账本权威,设计 §3.6)。故本方法是设计排定的接缝,非孤儿。
+ *
+ *
状态机守卫:仅当 {@code canTransit(当前状态, ACTIVE)} 才允许(待审→已激活=激活;已激活→已激活=回滚重放);
+ * 草稿态直接激活(未定版)或归档态激活均被拒(AIGC_CONFIG_STATUS_ILLEGAL)。版本须归属该配置集。
+ *
+ * @param configSetId 配置集 ID
+ * @param versionId 要激活/回滚到的版本行 ID(须归属该配置集)
+ */
+ void markVersionActivated(Long configSetId, Long versionId);
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImpl.java
new file mode 100644
index 00000000..7a016d65
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImpl.java
@@ -0,0 +1,388 @@
+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.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;
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetSaveDraftReqVO;
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigVersionDiffRespVO;
+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.dal.mysql.config.GenConfigSetMapper;
+import com.wanxiang.huijing.game.module.aigc.dal.mysql.config.GenConfigVersionMapper;
+import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum;
+import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigTierEnum;
+import com.wanxiang.huijing.framework.common.pojo.PageResult;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+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_CONTENT_INVALID_JSON;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_DRAFT_EMPTY;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_SET_ARCHIVED;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_SET_KEY_DUPLICATE;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_SET_NOT_EXISTS;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_STATUS_ILLEGAL;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_TIER_INVALID;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_VERSION_CONFLICT;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_VERSION_NOT_EXISTS;
+import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
+
+/**
+ * 生成配置治理 Service 实现(配置控制面阶段二 · yudao 配置中心 步骤1+2)
+ *
+ *
全部治理业务规则(状态机流转、归属边界、定版单库事务、值域基础校验、应用层 diff)在此承载、可单测、可追溯。
+ * creator/updater 由 yudao {@code DefaultDBFieldHandler} 从登录态自动填充(归因到人),本类不手工设审计列。
+ * 激活的双路下发编排属阶段二步骤3,本类只在 {@link #markVersionActivated} 提供账本收口接缝(见其 Javadoc)。
+ *
+ * @author 绘境AI
+ */
+@Slf4j
+@Service
+public class GenConfigServiceImpl implements GenConfigService {
+
+ /** content_json 校验与 diff 用的 JSON 解析器(无共享可变状态,线程安全) */
+ private static final ObjectMapper JSON = new ObjectMapper();
+ /** 规范化拼接 prompt 与 content_json 的分隔符(算 hash/字节数用,避免两字段边界歧义) */
+ private static final String CONTENT_SEP = " ";
+
+ @Resource
+ private GenConfigSetMapper configSetMapper;
+
+ @Resource
+ private GenConfigVersionMapper configVersionMapper;
+
+ // ==================== 步骤1:配置集建模 + 治理骨架 ====================
+
+ @Override
+ public Long createConfigSet(GenConfigSetCreateReqVO reqVO) {
+ // ① 档位合法性(仅 cheap/tier2)
+ if (!AigcConfigTierEnum.isValid(reqVO.getTier())) {
+ throw exception(AIGC_CONFIG_TIER_INVALID);
+ }
+ // ② 初始草稿内容若带 content_json,须为合法 JSON(避免脏内容落库)
+ validateJsonIfPresent(reqVO.getDraftContentJson());
+ // ③ 业务键查重(uk_set_key 兜底并发)
+ if (configSetMapper.selectBySetKey(reqVO.getSetKey()) != null) {
+ throw exception(AIGC_CONFIG_SET_KEY_DUPLICATE);
+ }
+ // ④ 落草稿态配置集
+ GenConfigSetDO set = new GenConfigSetDO();
+ set.setTier(reqVO.getTier());
+ set.setSetKey(reqVO.getSetKey());
+ set.setName(StringUtils.hasText(reqVO.getName()) ? reqVO.getName() : "");
+ set.setDescription(StringUtils.hasText(reqVO.getDescription()) ? reqVO.getDescription() : "");
+ set.setStatus(AigcConfigStatusEnum.DRAFT.getStatus());
+ set.setDraftPrompt(reqVO.getDraftPrompt());
+ set.setDraftContentJson(reqVO.getDraftContentJson());
+ set.setActiveVersionId(null); // 从未激活
+ try {
+ configSetMapper.insert(set);
+ } catch (DuplicateKeyException dup) {
+ // 并发同 set_key:唯一键冲突即返回业务键重复(幂等语义交调用方按已存在处理)
+ log.info("[gen-config] 配置集业务键并发冲突 setKey={}", reqVO.getSetKey());
+ throw exception(AIGC_CONFIG_SET_KEY_DUPLICATE);
+ }
+ log.info("[gen-config] 新建配置集 id={} tier={} setKey={}", set.getId(), reqVO.getTier(), reqVO.getSetKey());
+ return set.getId();
+ }
+
+ @Override
+ public void saveDraft(GenConfigSetSaveDraftReqVO reqVO) {
+ GenConfigSetDO set = getConfigSet(reqVO.getId());
+ // 已归档配置集只读(设计 §3.2)
+ assertNotArchived(set);
+ // content_json 若带上须合法
+ validateJsonIfPresent(reqVO.getDraftContentJson());
+ // 局部更新:只改带上的字段(null=不改),编辑不改 status、不动 active_version_id
+ GenConfigSetDO update = new GenConfigSetDO();
+ update.setId(set.getId());
+ update.setName(reqVO.getName());
+ update.setDescription(reqVO.getDescription());
+ update.setDraftPrompt(reqVO.getDraftPrompt());
+ update.setDraftContentJson(reqVO.getDraftContentJson());
+ configSetMapper.updateById(update);
+ log.info("[gen-config] 改配置集草稿 id={}(status 不变={})", set.getId(), set.getStatus());
+ }
+
+ @Override
+ public GenConfigSetDO getConfigSet(Long id) {
+ GenConfigSetDO set = configSetMapper.selectById(id);
+ if (set == null) {
+ throw exception(AIGC_CONFIG_SET_NOT_EXISTS);
+ }
+ return set;
+ }
+
+ @Override
+ public PageResult getConfigSetPage(GenConfigSetPageReqVO reqVO) {
+ return configSetMapper.selectPage(reqVO);
+ }
+
+ @Override
+ public void archiveConfigSet(Long id) {
+ GenConfigSetDO set = getConfigSet(id);
+ assertTransit(set.getStatus(), AigcConfigStatusEnum.ARCHIVED);
+ GenConfigSetDO update = new GenConfigSetDO();
+ update.setId(id);
+ update.setStatus(AigcConfigStatusEnum.ARCHIVED.getStatus());
+ configSetMapper.updateById(update);
+ log.info("[gen-config] 归档配置集 id={}", id);
+ }
+
+ // ==================== 步骤2:版本行与历史(MySQL) ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class) // 定版 = 单库事务(版本行落库 + 状态转待审),无跨系统写
+ public Long freezeVersion(GenConfigFreezeReqVO reqVO) {
+ GenConfigSetDO set = getConfigSet(reqVO.getId());
+ // 已归档不可定版
+ assertNotArchived(set);
+ // 草稿内容非空(prompt 或 content_json 至少一个有内容)——定版须有可快照的内容
+ boolean hasPrompt = StringUtils.hasText(set.getDraftPrompt());
+ boolean hasContent = StringUtils.hasText(set.getDraftContentJson());
+ if (!hasPrompt && !hasContent) {
+ throw exception(AIGC_CONFIG_DRAFT_EMPTY);
+ }
+ // content_json 合法性防御性再校验(保存时已校验,定版是落不可变版本行的最后一道闸)
+ validateJsonIfPresent(set.getDraftContentJson());
+
+ // 分配下一个版本号(max+1,uk_set_version 兜底并发)
+ Integer maxVersion = configVersionMapper.selectMaxVersion(set.getId());
+ int nextVersion = (maxVersion == null ? 0 : maxVersion) + 1;
+
+ // 快照草稿内容 → 不可变版本行;算 content_hash + content_bytes(utf8mb4 字节数,按字节非字符)
+ String canonical = canonicalContent(set.getDraftPrompt(), set.getDraftContentJson());
+ GenConfigVersionDO version = new GenConfigVersionDO();
+ version.setConfigSetId(set.getId());
+ version.setVersion(nextVersion);
+ version.setPrompt(set.getDraftPrompt());
+ version.setContentJson(set.getDraftContentJson());
+ version.setContentHash(sha256Hex(canonical));
+ version.setContentBytes((long) canonical.getBytes(StandardCharsets.UTF_8).length);
+ version.setChangeNote(StringUtils.hasText(reqVO.getChangeNote()) ? reqVO.getChangeNote() : "");
+ try {
+ configVersionMapper.insert(version);
+ } catch (DuplicateKeyException dup) {
+ // uk_set_version 撞键:并发定版抢同一版本号,判冲突可重试(静默期单操作者下极罕见)
+ log.warn("[gen-config] 定版版本号冲突 setId={} version={}", set.getId(), nextVersion);
+ throw exception(AIGC_CONFIG_VERSION_CONFLICT);
+ }
+
+ // 状态转「待审」(定版不动当前激活版指针)
+ GenConfigSetDO update = new GenConfigSetDO();
+ update.setId(set.getId());
+ update.setStatus(AigcConfigStatusEnum.PENDING_REVIEW.getStatus());
+ configSetMapper.updateById(update);
+
+ log.info("[gen-config] 定版 setId={} versionId={} version={} contentBytes={} contentHash={}",
+ set.getId(), version.getId(), nextVersion, version.getContentBytes(), version.getContentHash());
+ return version.getId();
+ }
+
+ @Override
+ public List listVersions(Long configSetId) {
+ // 校验配置集存在(防列他集历史)
+ getConfigSet(configSetId);
+ return configVersionMapper.selectListBySetId(configSetId);
+ }
+
+ @Override
+ public GenConfigVersionDO getVersion(Long configSetId, Long versionId) {
+ GenConfigVersionDO version = configVersionMapper.selectByIdAndSetId(versionId, configSetId);
+ if (version == null) {
+ throw exception(AIGC_CONFIG_VERSION_NOT_EXISTS);
+ }
+ return version;
+ }
+
+ @Override
+ public GenConfigVersionDiffRespVO diffVersions(Long configSetId, Long versionAId, Long versionBId) {
+ GenConfigVersionDO a = getVersion(configSetId, versionAId);
+ GenConfigVersionDO b = getVersion(configSetId, versionBId);
+
+ GenConfigVersionDiffRespVO resp = new GenConfigVersionDiffRespVO();
+ resp.setConfigSetId(configSetId);
+ resp.setVersionAId(a.getId());
+ resp.setVersionANo(a.getVersion());
+ resp.setVersionBId(b.getId());
+ resp.setVersionBNo(b.getVersion());
+ // prompt 大字段只给「是否变更 + 两侧字节数」,不逐字符 diff
+ resp.setPromptChanged(!nz(a.getPrompt()).equals(nz(b.getPrompt())));
+ resp.setPromptBytesA((long) nz(a.getPrompt()).getBytes(StandardCharsets.UTF_8).length);
+ resp.setPromptBytesB((long) nz(b.getPrompt()).getBytes(StandardCharsets.UTF_8).length);
+ resp.setContentHashA(a.getContentHash());
+ resp.setContentHashB(b.getContentHash());
+ // 结构化旋钮拍平成点路径逐项 diff
+ resp.setKnobChanges(diffContentJson(a.getContentJson(), b.getContentJson()));
+ return resp;
+ }
+
+ // ==================== 激活账本收口(步骤3 接缝) ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void markVersionActivated(Long configSetId, Long versionId) {
+ // 【步骤3 接管双路下发】本方法只做账本收口:状态转 ACTIVE + 当前激活版指针前移。
+ // 完整激活的值域校验与双路下发(PATCH /agent+/session、publish Nacos)在步骤3 激活编排里、于调用本方法之前完成;
+ // 下发全部成功才调本方法,任一路失败则不调(指针不前移、保留上一激活版为账本权威,设计 §3.6)。
+ GenConfigSetDO set = getConfigSet(configSetId);
+ // 版本须归属该配置集
+ GenConfigVersionDO version = getVersion(configSetId, versionId);
+ // 状态机守卫:待审→已激活(激活)或已激活→已激活(回滚重放);草稿直接激活/归档态激活均拒
+ assertTransit(set.getStatus(), AigcConfigStatusEnum.ACTIVE);
+
+ GenConfigSetDO update = new GenConfigSetDO();
+ update.setId(configSetId);
+ update.setStatus(AigcConfigStatusEnum.ACTIVE.getStatus());
+ update.setActiveVersionId(version.getId());
+ configSetMapper.updateById(update);
+ log.info("[gen-config] 激活账本收口 setId={} activeVersionId={} version={}(双路下发由步骤3 前置完成)",
+ configSetId, version.getId(), version.getVersion());
+ }
+
+ // ==================== 内部工具 ====================
+
+ /** 断言配置集非归档态(归档只读) */
+ private void assertNotArchived(GenConfigSetDO set) {
+ if (AigcConfigStatusEnum.ARCHIVED.getStatus().equals(set.getStatus())) {
+ throw exception(AIGC_CONFIG_SET_ARCHIVED);
+ }
+ }
+
+ /** 断言状态机流转合法(非法抛 AIGC_CONFIG_STATUS_ILLEGAL) */
+ private void assertTransit(Integer fromStatus, AigcConfigStatusEnum to) {
+ AigcConfigStatusEnum from = AigcConfigStatusEnum.of(fromStatus);
+ if (!AigcConfigStatusEnum.canTransit(from, to)) {
+ log.warn("[gen-config] 状态非法流转 from={} to={}", fromStatus, to);
+ throw exception(AIGC_CONFIG_STATUS_ILLEGAL);
+ }
+ }
+
+ /** content_json 非空则校验为合法 JSON(非法抛 AIGC_CONFIG_CONTENT_INVALID_JSON) */
+ private void validateJsonIfPresent(String contentJson) {
+ if (!StringUtils.hasText(contentJson)) {
+ return;
+ }
+ try {
+ JSON.readTree(contentJson);
+ } catch (Exception e) {
+ log.warn("[gen-config] content_json 非合法 JSON len={}", contentJson.length());
+ throw exception(AIGC_CONFIG_CONTENT_INVALID_JSON);
+ }
+ }
+
+ /** 规范化拼接 prompt 与 content_json(算 hash 与字节数用;两字段间以 NUL 分隔避免边界歧义) */
+ private static String canonicalContent(String prompt, String contentJson) {
+ return nz(prompt) + CONTENT_SEP + nz(contentJson);
+ }
+
+ /** SHA-256 十六进制(不引外部依赖,用 JDK MessageDigest + 手工 hex) */
+ private static String sha256Hex(String s) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(64);
+ for (byte b : digest) {
+ sb.append(Character.forDigit((b >> 4) & 0xF, 16));
+ sb.append(Character.forDigit(b & 0xF, 16));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 是 JDK 标配算法,理论不可达
+ throw new IllegalStateException("SHA-256 算法不可用", e);
+ }
+ }
+
+ /** 两份 content_json 的应用层 diff:各拍平成点路径 Map,逐键比对给增/删/改 */
+ private List diffContentJson(String jsonA, String jsonB) {
+ Map flatA = flattenJson(jsonA);
+ Map flatB = flattenJson(jsonB);
+ List changes = new ArrayList<>();
+ // 稳定顺序:两侧键并集有序遍历
+ TreeSet keys = new TreeSet<>();
+ keys.addAll(flatA.keySet());
+ keys.addAll(flatB.keySet());
+ for (String key : keys) {
+ boolean inA = flatA.containsKey(key);
+ boolean inB = flatB.containsKey(key);
+ String valA = flatA.get(key);
+ String valB = flatB.get(key);
+ if (inA && inB) {
+ if (!nz(valA).equals(nz(valB))) {
+ changes.add(buildChange(key, "CHANGED", valA, valB));
+ }
+ } else if (inB) {
+ changes.add(buildChange(key, "ADDED", null, valB));
+ } else {
+ changes.add(buildChange(key, "REMOVED", valA, null));
+ }
+ }
+ return changes;
+ }
+
+ private GenConfigVersionDiffRespVO.KnobChange buildChange(String path, String type, String valA, String valB) {
+ GenConfigVersionDiffRespVO.KnobChange c = new GenConfigVersionDiffRespVO.KnobChange();
+ c.setPath(path);
+ c.setChangeType(type);
+ c.setValueA(valA);
+ c.setValueB(valB);
+ return c;
+ }
+
+ /**
+ * 把一段 JSON 拍平成「点路径 → 叶子值字符串」的有序 Map(对象用 .key,数组用 [i])。
+ * 空/非法 JSON 兜底:返回空 Map(内容已在定版前校验合法;此处防御非法/空只影响 diff 呈现、不抛)。
+ */
+ private Map flattenJson(String json) {
+ Map flat = new LinkedHashMap<>();
+ if (!StringUtils.hasText(json)) {
+ return flat;
+ }
+ try {
+ flattenNode(JSON.readTree(json), "", flat);
+ } catch (Exception e) {
+ // 非法 JSON 不应到这(定版已校验),防御性整块作单叶子,便于 diff 显示「内容变化」
+ flat.put("", json);
+ }
+ return flat;
+ }
+
+ private void flattenNode(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();
+ flattenNode(e.getValue(), path, out);
+ }
+ } else if (node.isArray()) {
+ for (int i = 0; i < node.size(); i++) {
+ flattenNode(node.get(i), prefix + "[" + i + "]", out);
+ }
+ } else {
+ // 叶子(含 null):路径为空时(顶层非对象/数组)用
+ out.put(prefix.isEmpty() ? "" : prefix, node.isNull() ? "null" : node.asText());
+ }
+ }
+
+ /** null 安全转空串 */
+ private static String nz(String s) {
+ return s == null ? "" : s;
+ }
+
+}
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
new file mode 100644
index 00000000..15532fcd
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/config/AdminGenConfigControllerTest.java
@@ -0,0 +1,72 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link AdminGenConfigController} 权限门测试——反射校验每个端点都挂了 @PreAuthorize 且权限串正确。
+ *
+ * 「权限门挡住无权写」是声明式的:@PreAuthorize + Spring Security + 网关双重校验。本测试以反射坐实
+ * 每个写端点(建/改/定版/归档)挂的是写权限(save/freeze/archive)、每个读端点挂的是查询权限(query),
+ * 防止后续改动漏挂或挂错权限串(编译期无法保证注解存在,故补一道测试门)。
+ * 激活端点属阶段二步骤3、本波不暴露,故不在此校验。
+ *
+ * @author 绘境AI
+ */
+class AdminGenConfigControllerTest {
+
+ /** 每个 public 端点方法都必须挂 @PreAuthorize,且权限串符合读写分层。 */
+ @Test
+ void testEveryEndpointHasCorrectPreAuthorize() throws Exception {
+ assertPermission("createConfigSet", "aigc:config:save", GenConfigSetCreateReqType());
+ assertPermission("updateDraft", "aigc:config:save", GenConfigSetSaveDraftReqType());
+ assertPermission("freezeVersion", "aigc:config:freeze", GenConfigFreezeReqType());
+ assertPermission("archiveConfigSet", "aigc:config:archive", Long.class);
+ assertPermission("getConfigSet", "aigc:config:query", Long.class);
+ assertPermission("getConfigSetPage", "aigc:config:query", GenConfigSetPageReqType());
+ assertPermission("listVersions", "aigc:config:query", Long.class);
+ assertPermission("getVersion", "aigc:config:query", Long.class, Long.class);
+ assertPermission("diffVersions", "aigc:config:query", Long.class, Long.class, Long.class);
+ }
+
+ private void assertPermission(String method, String expectedPermission, Class>... paramTypes) throws Exception {
+ Method m = AdminGenConfigController.class.getMethod(method, paramTypes);
+ PreAuthorize pre = m.getAnnotation(PreAuthorize.class);
+ assertNotNull(pre, "端点 " + method + " 必须挂 @PreAuthorize(权限门)");
+ assertTrue(pre.value().contains(expectedPermission),
+ "端点 " + method + " 的权限串应含 " + expectedPermission + ",实际=" + pre.value());
+ }
+
+ // 参数类型解析(VO 为 controller 包内类型,反射按名取)
+ private static Class> GenConfigSetCreateReqType() throws Exception {
+ return Class.forName("com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetCreateReqVO");
+ }
+
+ private static Class> GenConfigSetSaveDraftReqType() throws Exception {
+ return Class.forName("com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetSaveDraftReqVO");
+ }
+
+ private static Class> GenConfigFreezeReqType() throws Exception {
+ return Class.forName("com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigFreezeReqVO");
+ }
+
+ private static Class> GenConfigSetPageReqType() throws Exception {
+ return Class.forName("com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigSetPageReqVO");
+ }
+
+ /** 写端点数 = 4(建/改/定版/归档),确保读写端点齐全(防漏挂)。 */
+ @Test
+ void testEndpointCount() {
+ long endpoints = java.util.Arrays.stream(AdminGenConfigController.class.getDeclaredMethods())
+ .filter(m -> m.isAnnotationPresent(PreAuthorize.class))
+ .count();
+ assertEquals(9, endpoints, "应有 9 个受权限门保护的端点(4 写 + 5 读)");
+ }
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigStatusEnumTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigStatusEnumTest.java
new file mode 100644
index 00000000..4228b5cc
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigStatusEnumTest.java
@@ -0,0 +1,67 @@
+package com.wanxiang.huijing.game.module.aigc.enums;
+
+import org.junit.jupiter.api.Test;
+
+import static com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum.ACTIVE;
+import static com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum.ARCHIVED;
+import static com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum.DRAFT;
+import static com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum.PENDING_REVIEW;
+import static com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum.canTransit;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link AigcConfigStatusEnum} 状态机测试——逐条把守设计 §3.2 的合法/非法流转边。
+ *
+ * @author 绘境AI
+ */
+class AigcConfigStatusEnumTest {
+
+ /** 合法边(对齐设计 §3.2 状态图)。 */
+ @Test
+ void testLegalTransitions() {
+ assertTrue(canTransit(DRAFT, PENDING_REVIEW), "草稿→待审(定版)");
+ assertTrue(canTransit(PENDING_REVIEW, ACTIVE), "待审→已激活(激活)");
+ assertTrue(canTransit(PENDING_REVIEW, DRAFT), "待审→草稿(驳回)");
+ assertTrue(canTransit(PENDING_REVIEW, PENDING_REVIEW), "待审→待审(编辑后再定版)");
+ assertTrue(canTransit(ACTIVE, PENDING_REVIEW), "已激活→待审(编辑出新版本)");
+ assertTrue(canTransit(ACTIVE, ACTIVE), "已激活→已激活(回滚重放)");
+ // 归档入边:非归档态均可归档
+ assertTrue(canTransit(DRAFT, ARCHIVED), "草稿→已归档");
+ assertTrue(canTransit(PENDING_REVIEW, ARCHIVED), "待审→已归档");
+ assertTrue(canTransit(ACTIVE, ARCHIVED), "已激活→已归档");
+ }
+
+ /** 非法边——越过定版直接激活、终态再流出、不合理跨态。 */
+ @Test
+ void testIllegalTransitions() {
+ assertFalse(canTransit(DRAFT, ACTIVE), "草稿不可直接激活(须先定版)");
+ assertFalse(canTransit(ACTIVE, DRAFT), "已激活不可直接回草稿");
+ // 归档为终态,不允许再流出
+ assertFalse(canTransit(ARCHIVED, DRAFT), "已归档不可再流出");
+ assertFalse(canTransit(ARCHIVED, PENDING_REVIEW), "已归档不可再流出");
+ assertFalse(canTransit(ARCHIVED, ACTIVE), "已归档不可再流出");
+ assertFalse(canTransit(ARCHIVED, ARCHIVED), "已归档不可再归档");
+ }
+
+ /** null 入参防御:任一端为 null 均判非法。 */
+ @Test
+ void testNullGuard() {
+ assertFalse(canTransit(null, ACTIVE));
+ assertFalse(canTransit(DRAFT, null));
+ assertFalse(canTransit(null, null));
+ }
+
+ /** of() 值映射。 */
+ @Test
+ void testOf() {
+ assertEquals(DRAFT, AigcConfigStatusEnum.of(0));
+ assertEquals(PENDING_REVIEW, AigcConfigStatusEnum.of(10));
+ assertEquals(ACTIVE, AigcConfigStatusEnum.of(20));
+ assertEquals(ARCHIVED, AigcConfigStatusEnum.of(30));
+ assertNull(AigcConfigStatusEnum.of(99), "非法值返回 null");
+ }
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigGovernanceRealDbIT.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigGovernanceRealDbIT.java
new file mode 100644
index 00000000..50744513
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigGovernanceRealDbIT.java
@@ -0,0 +1,359 @@
+package com.wanxiang.huijing.game.module.aigc.service.config;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.SQLIntegrityConstraintViolationException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 配置治理「真 MySQL」集成证据——连 mini-infra 真 MySQL({@code 100.64.0.8:3306}·Tailscale 直连·绕系统 SOCKS 代理),
+ * 在 throwaway 库里跑 ① V1..V29 全量迁移按版本序真库全应用(Flyway replay 全绿,多语句交 MySQL 服务端解析),
+ * ② V29 配置治理两表的存取行为:定版单表写、历史列表、任一版取回、30KB/64KB 边界 prompt 无截断(证 MEDIUMTEXT)、
+ * utf8mb4 字节口径(content_bytes 按字节非字符 + 含中文/emoji round-trip)、uk_set_version 幂等去重。
+ *
+ *
覆盖边界:GenConfigService 的分支逻辑(状态机/定版事务/diff)由 {@link GenConfigServiceImplTest} 单测坐实,
+ * 本 IT 用真 SQL 复演其存储序列、验真库口径。全程 throwaway 库 {@code aigc_config_gov_e2e} 建/删,隔离、不碰真库真表。
+ *
+ *
默认跳过:{@code @EnabledIfSystemProperty(aigc.config.e2e=1)}——常规 {@code mvn test} 不连 MySQL;
+ * 出证据时 {@code mvn test -Daigc.config.e2e=1 -Dtest=GenConfigGovernanceRealDbIT}。
+ *
+ * @author 绘境AI
+ */
+@EnabledIfSystemProperty(named = "aigc.config.e2e", matches = "1")
+class GenConfigGovernanceRealDbIT {
+
+ private static final String HOST = System.getProperty("aigc.config.dbhost", "100.64.0.8");
+ private static final String USER = System.getProperty("aigc.config.dbuser", "root");
+ // 内网 MVP 凭据(铁律授权入仓,见 docs/内网凭据与端点.md);可经 -D 覆盖
+ private static final String PASS = System.getProperty("aigc.config.dbpass", "ZRH3jwYLOrntBcTAw29MW9BP");
+ private static final String DB = "aigc_config_gov_e2e";
+ /** 迁移文件名版本前缀:V..__ */
+ private static final Pattern VERSION = Pattern.compile("^V(\\d+)\\.(\\d+)\\.(\\d+)__");
+
+ private String baseUrl() {
+ // allowMultiQueries:迁移文件是多语句,交 MySQL 服务端逐句解析(比自研 SQL 切分稳,正确处理注释内分号)
+ return "jdbc:mysql://" + HOST + ":3306/?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC&allowMultiQueries=true";
+ }
+
+ /** 内网直连绕系统 SOCKS 代理(macOS SOCKSEnable 127.0.0.1:7897)——MySQL Connector/J 默认走 JVM socks,须显式禁 */
+ private static void bypassSocksProxy() {
+ System.setProperty("socksProxyHost", "");
+ System.setProperty("java.net.useSystemProxies", "false");
+ }
+
+ private Path migrationDir() {
+ // user.dir = game-cloud/game-module-aigc/game-module-aigc-server;上溯两级到 game-cloud,再入 huijing-server 迁移目录
+ return Paths.get(System.getProperty("user.dir"), "..", "..",
+ "huijing-server", "src", "main", "resources", "db", "migration").normalize();
+ }
+
+ // ==================== ① 全量 Flyway replay ====================
+
+ /** V1..V29 全量迁移按版本序在真库全应用(含新增 V29 与全部前置,证明 V29 与既有迁移可组合、replay 全绿)。 */
+ @Test
+ void fullFlywayReplay_v1ToV29_allGreen() throws Exception {
+ bypassSocksProxy();
+ List migrations = listMigrationsSorted();
+ assertTrue(migrations.size() >= 29, "迁移文件应 ≥29(V1..V29),实际=" + migrations.size());
+ // 断言 V29 在列且为最大版本
+ Path last = migrations.get(migrations.size() - 1);
+ assertTrue(last.getFileName().toString().startsWith("V29.0.0__"), "最大版本应为 V29.0.0,实际=" + last.getFileName());
+
+ try (Connection conn = DriverManager.getConnection(baseUrl(), USER, PASS)) {
+ recreateThrowawayDb(conn);
+ int applied = 0;
+ for (Path migration : migrations) {
+ applyMultiStatementSql(conn, Files.readString(migration, StandardCharsets.UTF_8));
+ applied++;
+ }
+ assertEquals(migrations.size(), applied, "全部迁移应逐个真库应用成功");
+ // 抽验 V29 两表确已建成
+ assertTableExists(conn, "game_aigc_config_set");
+ assertTableExists(conn, "game_aigc_config_version");
+ } finally {
+ dropThrowawayDb();
+ }
+ }
+
+ // ==================== ② V29 配置治理存取行为 ====================
+
+ /** V29 DDL 可跑 + 定版/历史/取回/边界 prompt 无截断/utf8mb4 字节/uk 幂等,全在真 MySQL 上验。 */
+ @Test
+ void v29ConfigGovernance_onRealMysql() throws Exception {
+ bypassSocksProxy();
+ String v29 = Files.readString(migrationFile("V29.0.0__create_aigc_config_governance.sql"), StandardCharsets.UTF_8);
+ try (Connection conn = DriverManager.getConnection(baseUrl(), USER, PASS)) {
+ recreateThrowawayDb(conn);
+ applyMultiStatementSql(conn, v29); // 只应用 V29 两表(standalone,无前置依赖)
+
+ long setId = insertConfigSet(conn, "cheap", "cheap-default");
+
+ // ── 定版 v1:普通内容(单表写,无跨系统)──
+ String p1 = "便宜档 system prompt v1";
+ String c1 = "{\"model\":\"MiniMax-M3\",\"session\":{\"maxTokens\":16000}}";
+ long v1 = insertVersion(conn, setId, 1, p1, c1);
+
+ // ── 定版 v2:30KB 级 prompt 完整存取无截断 ──
+ String p2 = repeat("绘", 30720); // 30720 汉字(utf8mb4 每字 3 字节 ≈ 90KB 字节,字符数 30720)
+ long v2 = insertVersion(conn, setId, 2, p2, "{\"maxTokens\":16000}");
+ assertEquals(30720, readPromptCharLen(conn, v2), "30KB 级 prompt 字符数应完整(无截断)");
+
+ // ── 定版 v3:64KB 边界样本,越过 TEXT(65535 字节)上限,证 MEDIUMTEXT 无截断 ──
+ String p3 = repeat("A", 70000); // 70000 ASCII 字节 > 65535(TEXT 上限);MEDIUMTEXT 才能完整存
+ long v3 = insertVersion(conn, setId, 3, p3, "{}");
+ assertEquals(70000, readPromptCharLen(conn, v3), "越过 64KB 的 prompt 应完整存取(证 MEDIUMTEXT,非 TEXT 截断)");
+ assertEquals(70000L, readPromptByteLen(conn, v3), "ASCII 下字节数==字符数==70000");
+
+ // ── utf8mb4 字节口径:含中文+emoji,content_bytes 按字节非字符 + round-trip 不丢 emoji ──
+ String pUtf = "游戏配置🎮测试"; // 6 中文 + 1 emoji(4字节)
+ String cUtf = "{\"k\":\"值🎯\"}";
+ long vUtf = insertVersion(conn, setId, 4, pUtf, cUtf);
+ assertEquals(pUtf, readPrompt(conn, vUtf), "中文+emoji prompt 应原样 round-trip(证 utf8mb4,非 utf8mb3 吞 emoji)");
+ long promptBytes = readPromptByteLen(conn, vUtf);
+ long promptChars = readPromptCharLen(conn, vUtf);
+ assertTrue(promptBytes > promptChars, "utf8mb4:字节数应 > 字符数(中文3B/emoji4B 撑开)promptBytes=" + promptBytes + " chars=" + promptChars);
+ long storedContentBytes = readContentBytes(conn, vUtf);
+ long expectContentBytes = (pUtf + " " + cUtf).getBytes(StandardCharsets.UTF_8).length;
+ assertEquals(expectContentBytes, storedContentBytes, "content_bytes 列应等于规范化内容的 utf8mb4 字节数(按字节计)");
+
+ // ── 历史列表:按版本号倒序 [4,3,2,1] ──
+ List versionsDesc = listVersionsDesc(conn, setId);
+ assertEquals(List.of(4, 3, 2, 1), versionsDesc, "历史列表应按版本号倒序");
+
+ // ── 任一历史版取回:v1 内容完整 ──
+ assertEquals(p1, readPrompt(conn, v1), "取回 v1 prompt 应完整");
+
+ // ── 当前激活版指针:激活动作前移(此处直接 UPDATE 复演账本收口)──
+ updateActivePointer(conn, setId, v2);
+ assertEquals(v2, readActiveVersionId(conn, setId), "active_version_id 应指向被激活版");
+
+ // ── uk_set_version 幂等:同 (config_set_id, version) 再插 → 唯一键冲突 ──
+ assertThrows(SQLIntegrityConstraintViolationException.class,
+ () -> insertVersion(conn, setId, 1, "dup", "{}"), "同配置集同版本号应被 uk_set_version 拒绝");
+ } finally {
+ dropThrowawayDb();
+ }
+ }
+
+ // ==================== 底层工具 ====================
+
+ private List listMigrationsSorted() throws IOException {
+ List list = new ArrayList<>();
+ try (var stream = Files.list(migrationDir())) {
+ stream.filter(p -> VERSION.matcher(p.getFileName().toString()).find()).forEach(list::add);
+ }
+ list.sort(Comparator.comparingInt(p -> majorVersion(p.getFileName().toString())));
+ return list;
+ }
+
+ private static int majorVersion(String fileName) {
+ Matcher m = VERSION.matcher(fileName);
+ if (!m.find()) {
+ throw new IllegalArgumentException("非法迁移文件名:" + fileName);
+ }
+ return Integer.parseInt(m.group(1));
+ }
+
+ private Path migrationFile(String name) {
+ return migrationDir().resolve(name);
+ }
+
+ private void recreateThrowawayDb(Connection conn) throws SQLException {
+ try (Statement st = conn.createStatement()) {
+ st.execute("DROP DATABASE IF EXISTS " + DB);
+ st.execute("CREATE DATABASE " + DB + " DEFAULT CHARACTER SET utf8mb4");
+ st.execute("USE " + DB);
+ }
+ }
+
+ private void dropThrowawayDb() {
+ try (Connection conn = DriverManager.getConnection(baseUrl(), USER, PASS);
+ Statement st = conn.createStatement()) {
+ st.execute("DROP DATABASE IF EXISTS " + DB);
+ } catch (SQLException e) {
+ // 清理失败不掩盖主断言(throwaway 库,残留无害)
+ System.err.println("[aigc-config-e2e] throwaway 库清理失败(忽略):" + e.getMessage());
+ }
+ }
+
+ /**
+ * 应用一份多语句 SQL:交 MySQL 服务端解析(allowMultiQueries),逐个 result/updateCount 排空以让任一语句的错误浮现。
+ * 比自研「按分号切分」稳——正确处理注释内分号(如 V1 的 COMMENT 'MVP 临时整包;M3...' 含分号)。
+ */
+ private void applyMultiStatementSql(Connection conn, String sql) throws SQLException {
+ try (Statement st = conn.createStatement()) {
+ boolean isResult = st.execute(sql);
+ while (true) {
+ if (isResult) {
+ try (ResultSet rs = st.getResultSet()) {
+ // 迁移一般无结果集;排空即可
+ while (rs.next()) { /* ignore */ }
+ }
+ } else if (st.getUpdateCount() == -1) {
+ break; // 无更多结果
+ }
+ isResult = st.getMoreResults();
+ }
+ }
+ }
+
+ private void assertTableExists(Connection conn, String table) throws SQLException {
+ try (PreparedStatement ps = conn.prepareStatement(
+ "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ? AND table_name = ?")) {
+ ps.setString(1, DB);
+ ps.setString(2, table);
+ try (ResultSet rs = ps.executeQuery()) {
+ rs.next();
+ assertEquals(1, rs.getInt(1), "表 " + table + " 应已建成");
+ }
+ }
+ }
+
+ /** 插入配置集(草稿态),返回自增 id。 */
+ private long insertConfigSet(Connection conn, String tier, String setKey) throws SQLException {
+ try (PreparedStatement ps = conn.prepareStatement(
+ "INSERT INTO game_aigc_config_set (tier, set_key, name, status) VALUES (?,?,?,0)",
+ Statement.RETURN_GENERATED_KEYS)) {
+ ps.setString(1, tier);
+ ps.setString(2, setKey);
+ ps.setString(3, "e2e-" + setKey);
+ ps.executeUpdate();
+ try (ResultSet rs = ps.getGeneratedKeys()) {
+ rs.next();
+ return rs.getLong(1);
+ }
+ }
+ }
+
+ /** 复演 freezeVersion 的存储:插入不可变版本行(算 content_hash/content_bytes),返回自增 id。 */
+ private long insertVersion(Connection conn, long configSetId, int version, String prompt, String contentJson)
+ throws SQLException {
+ String canonical = prompt + " " + contentJson;
+ long contentBytes = canonical.getBytes(StandardCharsets.UTF_8).length;
+ String contentHash = sha256Hex(canonical);
+ try (PreparedStatement ps = conn.prepareStatement(
+ "INSERT INTO game_aigc_config_version "
+ + "(config_set_id, version, prompt, content_json, content_hash, content_bytes) VALUES (?,?,?,?,?,?)",
+ Statement.RETURN_GENERATED_KEYS)) {
+ ps.setLong(1, configSetId);
+ ps.setInt(2, version);
+ ps.setString(3, prompt);
+ ps.setString(4, contentJson);
+ ps.setString(5, contentHash);
+ ps.setLong(6, contentBytes);
+ ps.executeUpdate();
+ try (ResultSet rs = ps.getGeneratedKeys()) {
+ rs.next();
+ return rs.getLong(1);
+ }
+ }
+ }
+
+ private String readPrompt(Connection conn, long versionId) throws SQLException {
+ return readString(conn, "SELECT prompt FROM game_aigc_config_version WHERE id = ?", versionId);
+ }
+
+ private long readPromptCharLen(Connection conn, long versionId) throws SQLException {
+ return readLong(conn, "SELECT CHAR_LENGTH(prompt) FROM game_aigc_config_version WHERE id = ?", versionId);
+ }
+
+ private long readPromptByteLen(Connection conn, long versionId) throws SQLException {
+ return readLong(conn, "SELECT LENGTH(prompt) FROM game_aigc_config_version WHERE id = ?", versionId);
+ }
+
+ private long readContentBytes(Connection conn, long versionId) throws SQLException {
+ return readLong(conn, "SELECT content_bytes FROM game_aigc_config_version WHERE id = ?", versionId);
+ }
+
+ private List listVersionsDesc(Connection conn, long configSetId) throws SQLException {
+ List out = new ArrayList<>();
+ try (PreparedStatement ps = conn.prepareStatement(
+ "SELECT version FROM game_aigc_config_version WHERE config_set_id = ? ORDER BY version DESC")) {
+ ps.setLong(1, configSetId);
+ try (ResultSet rs = ps.executeQuery()) {
+ while (rs.next()) {
+ out.add(rs.getInt(1));
+ }
+ }
+ }
+ return out;
+ }
+
+ private void updateActivePointer(Connection conn, long configSetId, long versionId) throws SQLException {
+ try (PreparedStatement ps = conn.prepareStatement(
+ "UPDATE game_aigc_config_set SET active_version_id = ?, status = 20 WHERE id = ?")) {
+ ps.setLong(1, versionId);
+ ps.setLong(2, configSetId);
+ ps.executeUpdate();
+ }
+ }
+
+ private long readActiveVersionId(Connection conn, long configSetId) throws SQLException {
+ return readLong(conn, "SELECT active_version_id FROM game_aigc_config_set WHERE id = ?", configSetId);
+ }
+
+ private String readString(Connection conn, String sql, long id) throws SQLException {
+ try (PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setLong(1, id);
+ try (ResultSet rs = ps.executeQuery()) {
+ rs.next();
+ return rs.getString(1);
+ }
+ }
+ }
+
+ private long readLong(Connection conn, String sql, long id) throws SQLException {
+ try (PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setLong(1, id);
+ try (ResultSet rs = ps.executeQuery()) {
+ rs.next();
+ return rs.getLong(1);
+ }
+ }
+ }
+
+ private static String repeat(String unit, int times) {
+ StringBuilder sb = new StringBuilder(unit.length() * times);
+ for (int i = 0; i < times; i++) {
+ sb.append(unit);
+ }
+ return sb.toString();
+ }
+
+ /** SHA-256 十六进制(与 GenConfigServiceImpl 同口径) */
+ private static String sha256Hex(String s) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(64);
+ for (byte b : digest) {
+ sb.append(Character.forDigit((b >> 4) & 0xF, 16));
+ sb.append(Character.forDigit(b & 0xF, 16));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ throw new IllegalStateException("SHA-256 不可用", e);
+ }
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImplTest.java
new file mode 100644
index 00000000..a70d4293
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImplTest.java
@@ -0,0 +1,378 @@
+package com.wanxiang.huijing.game.module.aigc.service.config;
+
+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.GenConfigSetSaveDraftReqVO;
+import com.wanxiang.huijing.game.module.aigc.controller.admin.config.vo.GenConfigVersionDiffRespVO;
+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.dal.mysql.config.GenConfigSetMapper;
+import com.wanxiang.huijing.game.module.aigc.dal.mysql.config.GenConfigVersionMapper;
+import com.wanxiang.huijing.game.module.aigc.enums.AigcConfigStatusEnum;
+import com.wanxiang.huijing.framework.common.exception.ServiceException;
+import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_CONTENT_INVALID_JSON;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_DRAFT_EMPTY;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_SET_ARCHIVED;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_SET_KEY_DUPLICATE;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_SET_NOT_EXISTS;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_STATUS_ILLEGAL;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_TIER_INVALID;
+import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_CONFIG_VERSION_NOT_EXISTS;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link GenConfigServiceImpl} 单元测试(纯 Mockito)——把守配置治理的状态机流转、定版单库事务、
+ * content_bytes 字节口径、权限拒绝的服务侧不变式、应用层 diff。
+ *
+ * @author 绘境AI
+ */
+class GenConfigServiceImplTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private GenConfigServiceImpl genConfigService;
+
+ @Mock
+ private GenConfigSetMapper configSetMapper;
+ @Mock
+ private GenConfigVersionMapper configVersionMapper;
+
+ // ==================== 建/改配置集 ====================
+
+ /** 新建配置集:合法档位/唯一键 → 落草稿态、插入被调、返回自增 id。 */
+ @Test
+ void testCreateConfigSet_ok() {
+ GenConfigSetCreateReqVO reqVO = new GenConfigSetCreateReqVO();
+ reqVO.setTier("cheap");
+ reqVO.setSetKey("cheap-default");
+ reqVO.setName("便宜档默认");
+ reqVO.setDraftContentJson("{\"model\":\"MiniMax-M3\"}");
+ when(configSetMapper.selectBySetKey("cheap-default")).thenReturn(null);
+ // 模拟自增主键回填
+ when(configSetMapper.insert(any(GenConfigSetDO.class))).thenAnswer(inv -> {
+ ((GenConfigSetDO) inv.getArgument(0)).setId(1024L);
+ return 1;
+ });
+
+ Long id = genConfigService.createConfigSet(reqVO);
+
+ assertEquals(1024L, id);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(GenConfigSetDO.class);
+ verify(configSetMapper).insert(captor.capture());
+ assertEquals(AigcConfigStatusEnum.DRAFT.getStatus(), captor.getValue().getStatus(), "新建应落草稿态");
+ assertEquals("cheap", captor.getValue().getTier());
+ }
+
+ /** 新建配置集:非法档位 → AIGC_CONFIG_TIER_INVALID,不查重不插入。 */
+ @Test
+ void testCreateConfigSet_invalidTier() {
+ GenConfigSetCreateReqVO reqVO = new GenConfigSetCreateReqVO();
+ reqVO.setTier("premium"); // 非 cheap/tier2
+ reqVO.setSetKey("x");
+ ServiceException ex = assertThrows(ServiceException.class, () -> genConfigService.createConfigSet(reqVO));
+ assertEquals(AIGC_CONFIG_TIER_INVALID.getCode(), ex.getCode());
+ verify(configSetMapper, never()).insert(any(GenConfigSetDO.class));
+ }
+
+ /** 新建配置集:业务键已存在 → AIGC_CONFIG_SET_KEY_DUPLICATE。 */
+ @Test
+ void testCreateConfigSet_duplicateKey() {
+ GenConfigSetCreateReqVO reqVO = new GenConfigSetCreateReqVO();
+ reqVO.setTier("tier2");
+ reqVO.setSetKey("tier2-default");
+ when(configSetMapper.selectBySetKey("tier2-default")).thenReturn(new GenConfigSetDO());
+ ServiceException ex = assertThrows(ServiceException.class, () -> genConfigService.createConfigSet(reqVO));
+ assertEquals(AIGC_CONFIG_SET_KEY_DUPLICATE.getCode(), ex.getCode());
+ verify(configSetMapper, never()).insert(any(GenConfigSetDO.class));
+ }
+
+ /** 新建配置集:草稿 content_json 非法 → AIGC_CONFIG_CONTENT_INVALID_JSON。 */
+ @Test
+ void testCreateConfigSet_invalidJson() {
+ GenConfigSetCreateReqVO reqVO = new GenConfigSetCreateReqVO();
+ reqVO.setTier("cheap");
+ reqVO.setSetKey("k");
+ reqVO.setDraftContentJson("{not-json"); // 非法 JSON
+ ServiceException ex = assertThrows(ServiceException.class, () -> genConfigService.createConfigSet(reqVO));
+ assertEquals(AIGC_CONFIG_CONTENT_INVALID_JSON.getCode(), ex.getCode());
+ }
+
+ /** 改草稿:已归档配置集拒绝编辑 → AIGC_CONFIG_SET_ARCHIVED,不更新。 */
+ @Test
+ void testSaveDraft_archivedRejected() {
+ GenConfigSetDO archived = setDO(1L, AigcConfigStatusEnum.ARCHIVED);
+ when(configSetMapper.selectById(1L)).thenReturn(archived);
+ GenConfigSetSaveDraftReqVO reqVO = new GenConfigSetSaveDraftReqVO();
+ reqVO.setId(1L);
+ reqVO.setDraftPrompt("新内容");
+ ServiceException ex = assertThrows(ServiceException.class, () -> genConfigService.saveDraft(reqVO));
+ assertEquals(AIGC_CONFIG_SET_ARCHIVED.getCode(), ex.getCode());
+ verify(configSetMapper, never()).updateById(any(GenConfigSetDO.class));
+ }
+
+ /** 改草稿:编辑不改状态——更新 DO 的 status 为 null(局部更新不触碰状态列)。 */
+ @Test
+ void testSaveDraft_doesNotChangeStatus() {
+ when(configSetMapper.selectById(1L)).thenReturn(setDO(1L, AigcConfigStatusEnum.ACTIVE));
+ GenConfigSetSaveDraftReqVO reqVO = new GenConfigSetSaveDraftReqVO();
+ reqVO.setId(1L);
+ reqVO.setDraftPrompt("编辑中的新 prompt");
+ genConfigService.saveDraft(reqVO);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(GenConfigSetDO.class);
+ verify(configSetMapper).updateById(captor.capture());
+ assertEquals(null, captor.getValue().getStatus(), "改草稿不应带上 status(不改状态)");
+ assertEquals(null, captor.getValue().getActiveVersionId(), "改草稿不应动激活指针");
+ }
+
+ /** 取配置集不存在 → AIGC_CONFIG_SET_NOT_EXISTS。 */
+ @Test
+ void testGetConfigSet_notExists() {
+ when(configSetMapper.selectById(9L)).thenReturn(null);
+ ServiceException ex = assertThrows(ServiceException.class, () -> genConfigService.getConfigSet(9L));
+ assertEquals(AIGC_CONFIG_SET_NOT_EXISTS.getCode(), ex.getCode());
+ }
+
+ // ==================== 定版(版本行 + 单库事务) ====================
+
+ /** 定版:草稿有内容 → version=1、状态转待审、插入版本行 + 更新配置集状态各一次。 */
+ @Test
+ void testFreezeVersion_firstVersion() {
+ GenConfigSetDO set = setDO(1L, AigcConfigStatusEnum.DRAFT);
+ set.setDraftPrompt("你好世界");
+ set.setDraftContentJson("{\"maxTokens\":16000}");
+ when(configSetMapper.selectById(1L)).thenReturn(set);
+ when(configVersionMapper.selectMaxVersion(1L)).thenReturn(null); // 尚无版本
+ when(configVersionMapper.insert(any(GenConfigVersionDO.class))).thenAnswer(inv -> {
+ ((GenConfigVersionDO) inv.getArgument(0)).setId(2048L);
+ return 1;
+ });
+
+ GenConfigFreezeReqVO reqVO = new GenConfigFreezeReqVO();
+ reqVO.setId(1L);
+ reqVO.setChangeNote("首版");
+ Long versionId = genConfigService.freezeVersion(reqVO);
+
+ assertEquals(2048L, versionId);
+ ArgumentCaptor vCaptor = ArgumentCaptor.forClass(GenConfigVersionDO.class);
+ verify(configVersionMapper).insert(vCaptor.capture());
+ assertEquals(1, vCaptor.getValue().getVersion(), "首版版本号=1");
+ assertNotNull(vCaptor.getValue().getContentHash(), "应算 content_hash");
+ assertEquals(64, vCaptor.getValue().getContentHash().length(), "sha256 十六进制=64 字符");
+ // 状态转待审
+ ArgumentCaptor sCaptor = ArgumentCaptor.forClass(GenConfigSetDO.class);
+ verify(configSetMapper).updateById(sCaptor.capture());
+ assertEquals(AigcConfigStatusEnum.PENDING_REVIEW.getStatus(), sCaptor.getValue().getStatus());
+ assertEquals(null, sCaptor.getValue().getActiveVersionId(), "定版不动激活指针");
+ }
+
+ /** 定版:已有 v1 → 下一版号=2(max+1 分配)。 */
+ @Test
+ void testFreezeVersion_incrementsVersion() {
+ GenConfigSetDO set = setDO(1L, AigcConfigStatusEnum.ACTIVE);
+ set.setDraftContentJson("{\"a\":1}");
+ when(configSetMapper.selectById(1L)).thenReturn(set);
+ when(configVersionMapper.selectMaxVersion(1L)).thenReturn(1);
+ when(configVersionMapper.insert(any(GenConfigVersionDO.class))).thenReturn(1);
+
+ GenConfigFreezeReqVO reqVO = new GenConfigFreezeReqVO();
+ reqVO.setId(1L);
+ genConfigService.freezeVersion(reqVO);
+
+ ArgumentCaptor vCaptor = ArgumentCaptor.forClass(GenConfigVersionDO.class);
+ verify(configVersionMapper).insert(vCaptor.capture());
+ assertEquals(2, vCaptor.getValue().getVersion(), "第二版版本号=2");
+ }
+
+ /** 定版:草稿 prompt 与 content_json 均空 → AIGC_CONFIG_DRAFT_EMPTY,不落版本行。 */
+ @Test
+ void testFreezeVersion_emptyDraft() {
+ GenConfigSetDO set = setDO(1L, AigcConfigStatusEnum.DRAFT); // draft 两字段都 null
+ when(configSetMapper.selectById(1L)).thenReturn(set);
+ GenConfigFreezeReqVO reqVO = new GenConfigFreezeReqVO();
+ reqVO.setId(1L);
+ ServiceException ex = assertThrows(ServiceException.class, () -> genConfigService.freezeVersion(reqVO));
+ assertEquals(AIGC_CONFIG_DRAFT_EMPTY.getCode(), ex.getCode());
+ verify(configVersionMapper, never()).insert(any(GenConfigVersionDO.class));
+ }
+
+ /** 定版:已归档 → AIGC_CONFIG_SET_ARCHIVED。 */
+ @Test
+ void testFreezeVersion_archivedRejected() {
+ GenConfigSetDO set = setDO(1L, AigcConfigStatusEnum.ARCHIVED);
+ set.setDraftPrompt("x");
+ when(configSetMapper.selectById(1L)).thenReturn(set);
+ GenConfigFreezeReqVO reqVO = new GenConfigFreezeReqVO();
+ reqVO.setId(1L);
+ ServiceException ex = assertThrows(ServiceException.class, () -> genConfigService.freezeVersion(reqVO));
+ assertEquals(AIGC_CONFIG_SET_ARCHIVED.getCode(), ex.getCode());
+ }
+
+ /**
+ * content_bytes 按 utf8mb4 字节数计、非字符数(含中文+emoji):定版后落库的 content_bytes 应 > 字符数,
+ * 且等于规范化内容的 UTF-8 字节长度。这是「utf8mb4 字节口径正确」的服务侧证据(真库口径由 IT 复验)。
+ */
+ @Test
+ void testFreezeVersion_contentBytesByByteNotChar() {
+ GenConfigSetDO set = setDO(1L, AigcConfigStatusEnum.DRAFT);
+ String prompt = "游戏配置🎮测试"; // 6 汉字/emoji:中文 3 字节、emoji 4 字节
+ set.setDraftPrompt(prompt);
+ when(configSetMapper.selectById(1L)).thenReturn(set);
+ when(configVersionMapper.selectMaxVersion(1L)).thenReturn(null);
+ when(configVersionMapper.insert(any(GenConfigVersionDO.class))).thenReturn(1);
+
+ GenConfigFreezeReqVO reqVO = new GenConfigFreezeReqVO();
+ reqVO.setId(1L);
+ genConfigService.freezeVersion(reqVO);
+
+ ArgumentCaptor vCaptor = ArgumentCaptor.forClass(GenConfigVersionDO.class);
+ verify(configVersionMapper).insert(vCaptor.capture());
+ long contentBytes = vCaptor.getValue().getContentBytes();
+ // 规范化内容 = prompt + " " + "" ;字节数应 > 字符数(多字节中文/emoji 撑开)
+ String canonical = prompt + " ";
+ long expectedBytes = canonical.getBytes(StandardCharsets.UTF_8).length;
+ assertEquals(expectedBytes, contentBytes, "content_bytes 应等于规范化内容 UTF-8 字节长度");
+ assertTrue(contentBytes > canonical.length(), "字节数应大于字符数(证明按字节非字符计)");
+ }
+
+ // ==================== 激活账本收口(状态机) ====================
+
+ /** 账本收口:待审 → 已激活,指针前移到该版本、状态转已激活。 */
+ @Test
+ void testMarkVersionActivated_pendingToActive() {
+ GenConfigSetDO set = setDO(1L, AigcConfigStatusEnum.PENDING_REVIEW);
+ when(configSetMapper.selectById(1L)).thenReturn(set);
+ when(configVersionMapper.selectByIdAndSetId(2048L, 1L)).thenReturn(versionDO(2048L, 1L, 1));
+
+ genConfigService.markVersionActivated(1L, 2048L);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(GenConfigSetDO.class);
+ verify(configSetMapper).updateById(captor.capture());
+ assertEquals(AigcConfigStatusEnum.ACTIVE.getStatus(), captor.getValue().getStatus());
+ assertEquals(2048L, captor.getValue().getActiveVersionId(), "激活指针应前移到该版本");
+ }
+
+ /** 账本收口:已激活 → 已激活(回滚重放)合法,指针前移到历史版。 */
+ @Test
+ void testMarkVersionActivated_rollbackActiveToActive() {
+ GenConfigSetDO set = setDO(1L, AigcConfigStatusEnum.ACTIVE);
+ when(configSetMapper.selectById(1L)).thenReturn(set);
+ when(configVersionMapper.selectByIdAndSetId(2000L, 1L)).thenReturn(versionDO(2000L, 1L, 1));
+ genConfigService.markVersionActivated(1L, 2000L); // 回滚到旧版
+ ArgumentCaptor captor = ArgumentCaptor.forClass(GenConfigSetDO.class);
+ verify(configSetMapper).updateById(captor.capture());
+ assertEquals(2000L, captor.getValue().getActiveVersionId());
+ }
+
+ /** 账本收口:草稿态直接激活(未定版)→ 状态非法流转 AIGC_CONFIG_STATUS_ILLEGAL,不更新。 */
+ @Test
+ void testMarkVersionActivated_draftToActiveIllegal() {
+ GenConfigSetDO set = setDO(1L, AigcConfigStatusEnum.DRAFT);
+ when(configSetMapper.selectById(1L)).thenReturn(set);
+ when(configVersionMapper.selectByIdAndSetId(2048L, 1L)).thenReturn(versionDO(2048L, 1L, 1));
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> genConfigService.markVersionActivated(1L, 2048L));
+ assertEquals(AIGC_CONFIG_STATUS_ILLEGAL.getCode(), ex.getCode());
+ verify(configSetMapper, never()).updateById(any(GenConfigSetDO.class));
+ }
+
+ /** 账本收口:版本不属于该配置集 → AIGC_CONFIG_VERSION_NOT_EXISTS。 */
+ @Test
+ void testMarkVersionActivated_versionNotBelong() {
+ GenConfigSetDO set = setDO(1L, AigcConfigStatusEnum.PENDING_REVIEW);
+ when(configSetMapper.selectById(1L)).thenReturn(set);
+ when(configVersionMapper.selectByIdAndSetId(2048L, 1L)).thenReturn(null); // 不归属
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> genConfigService.markVersionActivated(1L, 2048L));
+ assertEquals(AIGC_CONFIG_VERSION_NOT_EXISTS.getCode(), ex.getCode());
+ }
+
+ // ==================== 归档 ====================
+
+ /** 归档:已激活 → 已归档 合法。 */
+ @Test
+ void testArchive_activeToArchived() {
+ when(configSetMapper.selectById(1L)).thenReturn(setDO(1L, AigcConfigStatusEnum.ACTIVE));
+ genConfigService.archiveConfigSet(1L);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(GenConfigSetDO.class);
+ verify(configSetMapper).updateById(captor.capture());
+ assertEquals(AigcConfigStatusEnum.ARCHIVED.getStatus(), captor.getValue().getStatus());
+ }
+
+ /** 归档:已归档 → 已归档 非法(终态不可再流出)→ AIGC_CONFIG_STATUS_ILLEGAL。 */
+ @Test
+ void testArchive_archivedAgainIllegal() {
+ when(configSetMapper.selectById(1L)).thenReturn(setDO(1L, AigcConfigStatusEnum.ARCHIVED));
+ ServiceException ex = assertThrows(ServiceException.class, () -> genConfigService.archiveConfigSet(1L));
+ assertEquals(AIGC_CONFIG_STATUS_ILLEGAL.getCode(), ex.getCode());
+ }
+
+ // ==================== 版本取回 / diff ====================
+
+ /** 取版本不存在 → AIGC_CONFIG_VERSION_NOT_EXISTS。 */
+ @Test
+ void testGetVersion_notExists() {
+ when(configVersionMapper.selectByIdAndSetId(9L, 1L)).thenReturn(null);
+ ServiceException ex = assertThrows(ServiceException.class, () -> genConfigService.getVersion(1L, 9L));
+ assertEquals(AIGC_CONFIG_VERSION_NOT_EXISTS.getCode(), ex.getCode());
+ }
+
+ /** 两版 diff:结构化旋钮改值/新增/删除 + prompt 变更 都被检出(应用层拍平点路径比对)。 */
+ @Test
+ void testDiffVersions_knobChangesDetected() {
+ GenConfigVersionDO a = versionDO(10L, 1L, 1);
+ a.setPrompt("旧 prompt");
+ a.setContentJson("{\"model\":\"M3\",\"session\":{\"maxTokens\":8000},\"dropMe\":true}");
+ GenConfigVersionDO b = versionDO(11L, 1L, 2);
+ b.setPrompt("新 prompt 更长一点");
+ b.setContentJson("{\"model\":\"M3\",\"session\":{\"maxTokens\":16000},\"addMe\":42}");
+ when(configVersionMapper.selectByIdAndSetId(10L, 1L)).thenReturn(a);
+ when(configVersionMapper.selectByIdAndSetId(11L, 1L)).thenReturn(b);
+
+ GenConfigVersionDiffRespVO diff = genConfigService.diffVersions(1L, 10L, 11L);
+
+ assertTrue(diff.getPromptChanged(), "prompt 应判为已变更");
+ List changes = diff.getKnobChanges();
+ // maxTokens CHANGED、dropMe REMOVED、addMe ADDED;model 不变不入列表
+ assertTrue(changes.stream().anyMatch(c -> "session.maxTokens".equals(c.getPath())
+ && "CHANGED".equals(c.getChangeType()) && "8000".equals(c.getValueA()) && "16000".equals(c.getValueB())));
+ assertTrue(changes.stream().anyMatch(c -> "dropMe".equals(c.getPath()) && "REMOVED".equals(c.getChangeType())));
+ assertTrue(changes.stream().anyMatch(c -> "addMe".equals(c.getPath()) && "ADDED".equals(c.getChangeType())));
+ assertFalse(changes.stream().anyMatch(c -> "model".equals(c.getPath())), "未变的 model 不应入 diff");
+ }
+
+ // ==================== 测试夹具 ====================
+
+ private static GenConfigSetDO setDO(Long id, AigcConfigStatusEnum status) {
+ GenConfigSetDO set = new GenConfigSetDO();
+ set.setId(id);
+ set.setTier("cheap");
+ set.setSetKey("k-" + id);
+ set.setStatus(status.getStatus());
+ return set;
+ }
+
+ private static GenConfigVersionDO versionDO(Long id, Long setId, Integer version) {
+ GenConfigVersionDO v = new GenConfigVersionDO();
+ v.setId(id);
+ v.setConfigSetId(setId);
+ v.setVersion(version);
+ return v;
+ }
+
+}
diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V29.0.0__create_aigc_config_governance.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V29.0.0__create_aigc_config_governance.sql
new file mode 100644
index 00000000..2c741606
--- /dev/null
+++ b/game-cloud/huijing-server/src/main/resources/db/migration/V29.0.0__create_aigc_config_governance.sql
@@ -0,0 +1,71 @@
+-- =============================================================================
+-- 契约 #2 DB 迁移 | 模块:aigc(生成配置治理子域,配置控制面阶段二 步骤1+2)| owner:配置控制面
+-- 文件:V29.0.0__create_aigc_config_governance.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移)
+-- 内容:生成配置治理两表 —— 配置集 game_aigc_config_set + 配置版本行 game_aigc_config_version
+-- 背景:阶段一已让生成配置能热改(prompt/模型/参数经 /agent+/session 每 POST 现装配、预算/阈值经 genconfig 热取),
+-- 但缺一层治理——改动没有版本历史、没有归因、不能一键回滚、没有激活闸。本迁移在热配之上补版本化治理层:
+-- 创始人在 yudao 侧编辑一组生成配置,走「改 → 定版 → 审核挂点 → 激活」,版本历史/回滚/diff 落这两张 MySQL 表。
+-- 为何版本账本落 MySQL 而非 Nacos(设计 §3.1,创始人拍 B):① prompt 正文会随 few-shot 长大、越过 Nacos 单配置
+-- 100KB 上限;② 定版要原子(元数据+全量内容同库同事务一行落,避免跨系统写);③ diff 与按内容检索要就地。
+-- prompt 正文列取 MEDIUMTEXT(16MB)而非 TEXT(64KB):30KB 级 prompt 加 few-shot 有越过 64KB 的现实可能。
+-- 约定:InnoDB + utf8mb4;含 Yudao 审计列(creator/create_time/updater/update_time/deleted)+ 租户列 tenant_id。
+-- 错误码段:aigc = 1-101-***-***(配置治理用 004 子段,续编不跳段)。
+-- 双路激活的下发编排(PATCH /agent+/session、publish Nacos)= 阶段二 步骤3,本迁移只落治理账本结构,不含下发。
+-- =============================================================================
+
+-- -----------------------------------------------------------------------------
+-- 表:game_aigc_config_set —— 生成配置集(治理单元 + 草稿工作副本 + 当前激活版指针 + 生命周期状态)
+-- 治理单元是「配置集」而非单个旋钮:一款档位的生成行为由一组协同旋钮共同决定(prompt+模型+参数+预算+门阈值),
+-- 以配置集为激活单元,才能让一组本该一起变的旋钮一次激活、不出现「新 prompt 配旧模型」的半拉子状态(设计 §3.2)。
+-- 草稿工作副本(draft_*):编辑中的内容存在配置集行上(可反复改),定版时快照进版本行;版本行一旦落库不可变(只增不删)。
+-- 生命周期 status:0草稿 → 10待审(定版)→ 20已激活(激活);20→10(编辑出新版本再定版);20→20(回滚重放激活);→30已归档。
+-- active_version_id(当前激活版指针):只由激活动作前移,定版不动它——保证「编辑中/待审」与「已生效」清晰分开(设计 §3.2)。
+-- -----------------------------------------------------------------------------
+CREATE TABLE `game_aigc_config_set` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '配置集 ID(= 对外 configSetId)',
+ `tier` VARCHAR(16) NOT NULL COMMENT '档位:cheap/tier2(决定 Nacos 生效 dataId 与 key 命名:cheap 用 cheap_rmb_hard_limit、tier2 用 rmb_hard_limit)',
+ `set_key` VARCHAR(64) NOT NULL COMMENT '配置集业务键(租户内唯一,如 cheap-default/tier2-default;uk_set_key 保证不重复)',
+ `name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '配置集显示名',
+ `description` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '配置集描述',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '生命周期状态:0草稿 10待审 20已激活 30已归档(AigcConfigStatusEnum)',
+ `draft_prompt` MEDIUMTEXT NULL COMMENT '编辑中 prompt 工作副本(定版时快照进版本行;MEDIUMTEXT 容 few-shot 增长,不进 Nacos)',
+ `draft_content_json` MEDIUMTEXT NULL COMMENT '编辑中结构化旋钮工作副本 JSON(model/参数/预算/门阈值 + 到 /agent+/session 字段与 Nacos key 的映射语义)',
+ `active_version_id` BIGINT NULL COMMENT '当前激活版指针(→ game_aigc_config_version.id;只由激活动作前移,定版不动它;NULL=尚无激活版)',
+ `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列,= 编辑者 yudao 登录用户,归因到人)',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(Yudao 审计列)',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删(配置集以归档 status=30 退场,正常不硬删)',
+ `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_set_key` (`set_key`) COMMENT '配置集业务键唯一(同一业务键只有一个配置集)',
+ KEY `idx_tier_status` (`tier`, `status`) COMMENT '按档位/状态检索配置集'
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '生成配置集(治理单元 + 草稿工作副本 + 当前激活版指针 + 生命周期状态;配置控制面阶段二)';
+
+-- -----------------------------------------------------------------------------
+-- 表:game_aigc_config_version —— 配置版本行(配置集全量内容快照,只增不删 / append-only)
+-- 定版(freezeVersion)把配置集的草稿内容快照成不可变的一行:prompt 正文(MEDIUMTEXT)+ 结构化旋钮全量(content_json)
+-- + 元数据(creator/时间随行)同库同事务一次写成(设计 §3.1 定版原子性)。
+-- prompt 正文列只做整行读写、不参与检索,故不建全文/普通索引;另存 content_hash 与 content_bytes 两列:
+-- content_hash = 全量内容 sha256,供快速比对/去重;content_bytes = 全量内容 utf8mb4 字节数(按字节计、非字符数,供容量核对)。
+-- 两版 diff 在应用层做(不在库内 diff);量级 = 人工定版频次(每天几次),多年也只几千行,只增不删、留归档策略位不做提前优化。
+-- -----------------------------------------------------------------------------
+CREATE TABLE `game_aigc_config_version` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '版本行 ID(= 对外 versionId;active_version_id 指向它)',
+ `config_set_id` BIGINT NOT NULL COMMENT '所属配置集 ID(→ game_aigc_config_set.id)',
+ `version` INT NOT NULL COMMENT '配置集内版本号(1 起单调递增;freezeVersion 取 max+1 分配,uk_set_version 兜底并发)',
+ `prompt` MEDIUMTEXT NULL COMMENT 'prompt 正文快照(最大、改得最勤的旋钮;MEDIUMTEXT 16MB 容 few-shot 增长;只整行读写、不索引、不进 Nacos)',
+ `content_json` MEDIUMTEXT NULL COMMENT '结构化旋钮全量快照 JSON(model/参数/预算/门阈值 + 到 /agent+/session 字段与 Nacos key 的映射语义)',
+ `content_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT '全量内容 sha256 十六进制(prompt+content_json 规范化拼接;快速比对/去重)',
+ `content_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT '全量内容 utf8mb4 字节数(按字节计、非字符数;含中文/emoji 时口径正确,供容量核对)',
+ `change_note` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '本次定版变更说明(可空)',
+ `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '定版者(Yudao 审计列,随版本落库、归因到人)',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '定版时间(随版本落库)',
+ `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(Yudao 审计列;版本行不可变,正常不更新)',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删(版本行只增不删,留位不使用)',
+ `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_set_version` (`config_set_id`, `version`) COMMENT '同一配置集版本号唯一(并发定版兜底)',
+ KEY `idx_set_id` (`config_set_id`, `id`) COMMENT '按配置集取历史版本(id 倒序即最新在前)'
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '配置版本行(配置集全量内容快照,只增不删;prompt MEDIUMTEXT 不进 Nacos;配置控制面阶段二)';