feat(aigc): 配置控制面阶段二 步骤1+2 生成配置治理子域(配置集建模+yudao治理骨架 / 版本行与历史 MySQL) (配置控制面阶段二 步骤1+2)

在 game-module-aigc 落生成配置治理子域,把散落的生成配置旋钮升级为「可版本、可回滚、可归因」的受治理
资产。治理单元=配置集(不塞 infra_config KV),走「改→定版→审核挂点→激活→回滚」生命周期(设计 §3.2)。
版本账本落 MySQL 版本行(prompt 存 MEDIUMTEXT、不进 Nacos):设计 §3.1 三约束——prompt 越 Nacos 100KB
上限 / 定版要原子(元数据+全量内容同库同事务一行) / diff 与检索要就地。

数据模型(V29,现行最大 V28→取 V29,replay 全绿):
- game_aigc_config_set:配置集(tier cheap/tier2 + set_key uk + 生命周期 status + 草稿工作副本 draft_prompt/
  draft_content_json + active_version_id 当前激活版指针[只由激活前移、定版不动])。
- game_aigc_config_version:版本行(全量快照,只增不删;prompt MEDIUMTEXT + content_json + content_hash +
  content_bytes[utf8mb4 字节数,按字节非字符] + uk_set_version 同集版本号唯一)。

能力面(admin,@PreAuthorize 权限门):建/改配置集(save)、定版(freeze,单库事务快照草稿→版本行、状态转待审、
不动激活指针)、历史列表/任一版取回/两版 diff(query,应用层拍平点路径逐项增删改)、归档(archive)。
状态机 AigcConfigStatusEnum 单点裁决合法流转。错误码续编 aigc 004 子段(不跳段)。

步骤3 接缝(非孤儿):激活的双路下发编排(值域校验 + PATCH /agent+/session + publish Nacos)属步骤3、本波不做;
激活的账本收口 GenConfigService#markVersionActivated(状态转 ACTIVE + 指针前移,纯 MySQL)已就位并单测,
步骤3 在双路下发成功后调它、失败不调(指针不前移、保留上一激活版为账本权威,设计 §3.6);controller 不暴露激活端点。

验证:aigc-server 单测绿(状态机流转 §3.2 / 权限门反射 / 定版事务 / content_bytes 字节口径 / diff;新增 26 测,
既有全不变);真 MySQL IT 绿(mini-infra 100.64.0.8,绕系统代理直连)——① V1..V29 全量迁移按版本序真库全应用
(replay 全绿,多语句交服务端解析,正确处理 V1 注释内分号)② 30KB/64KB 边界 prompt 无截断(证 MEDIUMTEXT 非 TEXT)
+ utf8mb4 字节口径(含中文/emoji round-trip、LENGTH>CHAR_LENGTH)+ uk_set_version 幂等;单体 huijing-server 编译绿。

范围说明:契约仅动 game-cloud;近期迁移 V26/V27/V28 均只在 db/migration(contracts/db-schemas 镜像自 V25 起已停维护,
R2 charge 亦未动 contracts),故 V29 循此现行先例、不动 contracts;新 admin 治理 API 未入 aigc.yaml 契约,与 R2 同——
待 fable 终审裁定是否补契约。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-04 09:32:21 -07:00
parent b74cd6155d
commit 790f53dc71
22 changed files with 2354 additions and 0 deletions

View File

@ -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)
*
* <p>治理单元是「配置集」,一个配置集走这样一条生命周期:
* <pre>
* [创建] ──────────────────────────────▶ 草稿(DRAFT)
* 草稿 ──定版(freezeVersion,版本行落库)──▶ 待审(PENDING_REVIEW)
* 待审 ──激活(审核通过,MVP 创始人直接激活)─▶ 已激活(ACTIVE)
* 已激活 ──编辑出新版本再定版────────────────▶ 待审 (旧版继续服务,active_version_id 不动)
* 已激活 ──回滚(取历史版本行重放激活)──────────▶ 已激活 (指针前移到历史版)
* 待审 ──驳回────────────────────────────▶ 草稿
* {草稿/待审/已激活} ──归档──────────────────▶ 已归档(ARCHIVED)
* </pre>
*
* <p><b>几条关键语义</b>:① 激活之前 Nacos/Service 完全不被触碰——草稿与版本行都只是治理库内数据,
* 到「激活」内容才第一次离开治理库(堵死「草稿提前生效」);② 编辑(改草稿)本身不改状态,
* 只有定版(→待审)与激活(→已激活)改状态;③ 当前激活版指针只由激活动作前移,定版不动它。
*
* <p><b>双路激活的下发编排(PATCH /agent+/session、publish Nacos)属阶段二步骤3</b>,本状态机只承载
* 治理侧的账本流转(状态位 + 指针前移 + 审计);步骤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);
}
/**
* 判断从当前状态到目标状态是否为合法流转(状态机唯一裁决处,服务层据此拒非法流转)。
*
* <p>合法边(对齐类注释状态图):
* <ul>
* <li>DRAFT → PENDING_REVIEW(定版)</li>
* <li>PENDING_REVIEW → PENDING_REVIEW(编辑后再定版,新待审版覆盖旧待审版)/ ACTIVE(激活)/ DRAFT(驳回)</li>
* <li>ACTIVE → PENDING_REVIEW(编辑出新版本再定版)/ ACTIVE(回滚重放激活)</li>
* <li>{DRAFT, PENDING_REVIEW, ACTIVE} → ARCHIVED(归档)</li>
* </ul>
* 归档态(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;
};
}
}

View File

@ -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)
*
* <p>档位是配置集的分组维度,决定这一组旋钮映射到哪一档的生效通道——尤其是路 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);
}
}

View File

@ -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, "配置版本号冲突,请重试");
}

View File

@ -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)
*
* <p>端前缀 /admin-api 由框架按包名 controller.admin.* 自动添加。鉴权:RBAC,@PreAuthorize + 网关双重校验;
* creator 由 yudao 登录态自动填充、归因到人。能力面(步骤1+2)= 建/改配置集、定版、历史列表、任一历史版取回、两版 diff。
*
* <p><b>激活端点不在本波</b>:双路激活(值域校验 + 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<Long> createConfigSet(@Valid @RequestBody GenConfigSetCreateReqVO reqVO) {
return success(genConfigService.createConfigSet(reqVO));
}
@PutMapping("/update-draft")
@Operation(summary = "改配置集草稿", description = "编辑草稿工作副本;不改状态、不动激活指针;已归档拒绝编辑")
@PreAuthorize("@ss.hasPermission('aigc:config:save')")
public CommonResult<Boolean> updateDraft(@Valid @RequestBody GenConfigSetSaveDraftReqVO reqVO) {
genConfigService.saveDraft(reqVO);
return success(true);
}
@PostMapping("/archive")
@Operation(summary = "归档配置集", description = "整集退场转已归档、只读")
@PreAuthorize("@ss.hasPermission('aigc:config:archive')")
public CommonResult<Boolean> 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<GenConfigSetRespVO> 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<PageResult<GenConfigSetRespVO>> getConfigSetPage(@Valid GenConfigSetPageReqVO pageReqVO) {
PageResult<GenConfigSetDO> 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<List<GenConfigVersionRespVO>> listVersions(@RequestParam("configSetId") Long configSetId) {
List<GenConfigVersionDO> versions = genConfigService.listVersions(configSetId);
// 列表摘要:剥离大字段 prompt/contentJson,避免历史列表拖大响应(详情取回见 /version/get)
List<GenConfigVersionRespVO> 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<GenConfigVersionRespVO> 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<GenConfigVersionDiffRespVO> 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<Long> freezeVersion(@Valid @RequestBody GenConfigFreezeReqVO reqVO) {
return success(genConfigService.freezeVersion(reqVO));
}
}

View File

@ -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 端)
*
* <p>定版(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;
}

View File

@ -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 端)
*
* <p>创建即落草稿态(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;
}

View File

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

View File

@ -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 端)
*
* <p>含配置集元数据 + 草稿工作副本 + 当前激活版指针 + 生命周期状态 + 审计字段(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;
}

View File

@ -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 端)
*
* <p>编辑配置集的草稿工作副本(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;
}

View File

@ -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)
*
* <p>两版 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<KnobChange> 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;
}
}

View File

@ -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 端)
*
* <p>历史版本快照的全量内容: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;
}

View File

@ -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,配置控制面阶段二治理单元)
*
* <p>治理单元是「配置集」而非单个旋钮:一款档位的生成行为由一组协同旋钮共同决定(prompt+模型+参数+预算+门阈值),
* 以配置集为激活单元,才能让一组本该一起变的旋钮一次激活、不出现「新 prompt 配旧模型」的半拉子状态(设计 §3.2)。
*
* <p>继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列,
* 故本类只声明业务字段。creator = 编辑者 yudao 登录用户,归因到人(设计目标:改动可审计、归因到人)。
*
* <p><b>草稿工作副本 draftPrompt/draftContentJson</b>:编辑中的内容存在配置集行上(可反复改),定版
* ({@code freezeVersion})时快照进不可变的版本行 {@link GenConfigVersionDO}。编辑本身不改 {@link #status},
* 只有定版(→待审)与激活(→已激活)改状态。
*
* <p><b>activeVersionId(当前激活版指针)</b>:只由激活动作前移,定版不动它——把「编辑中/待审」与「已生效」
* 清晰分开。双路激活的下发编排(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;
}

View File

@ -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)
*
* <p>定版({@code freezeVersion})把配置集的草稿内容快照成不可变的一行:prompt 正文({@link #prompt},MEDIUMTEXT)
* + 结构化旋钮全量({@link #contentJson})+ 元数据(creator/时间随行)同库同事务一次写成(设计 §3.1 定版原子性)。
*
* <p><b>为何 prompt 落 MySQL MEDIUMTEXT 而非 Nacos / MinIO</b>(设计 §3.1,创始人拍 B):① Nacos 单配置内容默认上限
* 100KB,prompt 加 few-shot 会向上限爬;② 定版要原子(内容在库外则定版成跨系统写);③ diff/按内容检索要就地;
* ④ MinIO 是「多文件源树」形状(成品源工程归档 seam),prompt 是「一段文本字段」形状,MinIO 的大对象/流式价值用不上。
* 列型取 MEDIUMTEXT(16MB)而非 TEXT(64KB):30KB 级 prompt 加 few-shot 有越过 64KB 的现实可能。
*
* <p>prompt 正文只做整行读写、不参与检索,故 DDL 不建全文/普通索引;另存 {@link #contentHash} 与 {@link #contentBytes}
* 两列供快速比对与容量核对。两版 diff 在应用层做(GenConfigService#diffVersions),不在库内 diff。
*
* <p>继承 {@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;
}

View File

@ -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<GenConfigSetDO> {
/**
* 按业务键取配置集(创建时查重、按键定位;uk_set_key 保证至多一条)。
* 租户由 yudao 多租户插件自动注入过滤。
*
* @param setKey 配置集业务键
* @return 配置集 DO;不存在返回 null
*/
default GenConfigSetDO selectBySetKey(String setKey) {
return selectOne(new LambdaQueryWrapperX<GenConfigSetDO>()
.eq(GenConfigSetDO::getSetKey, setKey));
}
/**
* 配置集分页(admin 端,按档位/状态/业务键筛选)
*
* @param reqVO 分页 + 可选 tier/status/setKey 筛选
* @return 分页结果(按 id 倒序,最新在前)
*/
default PageResult<GenConfigSetDO> selectPage(GenConfigSetPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<GenConfigSetDO>()
.eqIfPresent(GenConfigSetDO::getTier, reqVO.getTier())
.eqIfPresent(GenConfigSetDO::getStatus, reqVO.getStatus())
.likeIfPresent(GenConfigSetDO::getSetKey, reqVO.getSetKey())
.orderByDesc(GenConfigSetDO::getId));
}
}

View File

@ -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(配置集全量内容快照,只增不删)
*
* <p>历史列表靠 {@link #selectListBySetId}(按版本号倒序,最新在前);下一个版本号靠 {@link #selectMaxVersion}
* (max+1 分配,uk_set_version 兜底并发);取任一历史版靠 {@link #selectByIdAndSetId}(校验归属该配置集)。
*
* @author 绘境AI
*/
@Mapper
public interface GenConfigVersionMapper extends BaseMapperX<GenConfigVersionDO> {
/**
* 取配置集当前最大版本号(定版时 max+1 分配下一个版本号)。
*
* @param configSetId 配置集 ID
* @return 当前最大版本号;该配置集尚无版本行时返回 null
*/
default Integer selectMaxVersion(Long configSetId) {
// orderByDesc + LIMIT 1 取最大版本号一行;LIMIT 1 保证 ≤1 行,无 selectOne 多行风险
List<GenConfigVersionDO> list = selectList(new LambdaQueryWrapperX<GenConfigVersionDO>()
.eq(GenConfigVersionDO::getConfigSetId, configSetId)
.orderByDesc(GenConfigVersionDO::getVersion)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0).getVersion();
}
/**
* 配置集的历史版本列表(按版本号倒序,最新在前)。
*
* @param configSetId 配置集 ID
* @return 历史版本行列表(含全量内容;无则空列表)
*/
default List<GenConfigVersionDO> selectListBySetId(Long configSetId) {
return selectList(new LambdaQueryWrapperX<GenConfigVersionDO>()
.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<GenConfigVersionDO>()
.eq(GenConfigVersionDO::getId, id)
.eq(GenConfigVersionDO::getConfigSetId, configSetId));
}
}

View File

@ -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)
*
* <p>治理层把散落的生成配置旋钮升级为「可版本、可回滚、可归因、可审批的受治理资产」:以配置集为治理单元,
* 走「改 → 定版 → 审核挂点 → 激活 → 回滚」生命周期(设计 §3.2)。本 Service 承载步骤1+2 的能力面——
* 建/改配置集、定版、历史列表、任一历史版取回、两版 diff,以及激活的账本收口(状态位 + 指针前移)。
*
* <p><b>激活的双路下发编排(PATCH /agent+/session、publish Nacos)属阶段二步骤3</b>,不在本 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<GenConfigSetDO> 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<GenConfigVersionDO> 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、当前激活版指针前移到指定版本行。
*
* <p><b>这不是完整的激活动作。</b>完整激活(阶段二步骤3)= 值域 sanity 校验 → 双路下发(PATCH /agent+/session
* 写 Service Redis、publish 预算/门阈值到按档隔离的 Nacos 生效 dataId)→ <b>下发全部成功后</b>调用本方法把治理
* 账本收口。本方法只承载账本侧(MySQL 内的状态位 + 指针 + 审计),<b>不含任何跨系统下发</b>——那是步骤3 接管。
*
* <p>之所以在步骤2 就落这半:设计 §3.2 明确「当前激活版指针只由激活动作前移、定版不动它」,指针前移是治理账本
* 的数据行为、纯 MySQL;把它与双路下发解耦,步骤3 的激活编排只需在下发成功后调本方法,失败时不调(指针不前移、
* 保留上一激活版为账本权威,设计 §3.6)。故本方法是设计排定的接缝,非孤儿。
*
* <p>状态机守卫:仅当 {@code canTransit(当前状态, ACTIVE)} 才允许(待审→已激活=激活;已激活→已激活=回滚重放);
* 草稿态直接激活(未定版)或归档态激活均被拒(AIGC_CONFIG_STATUS_ILLEGAL)。版本须归属该配置集。
*
* @param configSetId 配置集 ID
* @param versionId 要激活/回滚到的版本行 ID(须归属该配置集)
*/
void markVersionActivated(Long configSetId, Long versionId);
}

View File

@ -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)
*
* <p>全部治理业务规则(状态机流转、归属边界、定版单库事务、值域基础校验、应用层 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<GenConfigSetDO> 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<GenConfigVersionDO> 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<GenConfigVersionDiffRespVO.KnobChange> diffContentJson(String jsonA, String jsonB) {
Map<String, String> flatA = flattenJson(jsonA);
Map<String, String> flatB = flattenJson(jsonB);
List<GenConfigVersionDiffRespVO.KnobChange> changes = new ArrayList<>();
// 稳定顺序:两侧键并集有序遍历
TreeSet<String> 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<String, String> flattenJson(String json) {
Map<String, String> flat = new LinkedHashMap<>();
if (!StringUtils.hasText(json)) {
return flat;
}
try {
flattenNode(JSON.readTree(json), "", flat);
} catch (Exception e) {
// 非法 JSON 不应到这(定版已校验),防御性整块作单叶子,便于 diff 显示「内容变化」
flat.put("<raw>", json);
}
return flat;
}
private void flattenNode(JsonNode node, String prefix, Map<String, String> out) {
if (node.isObject()) {
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> e = fields.next();
String path = prefix.isEmpty() ? e.getKey() : prefix + "." + e.getKey();
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):路径为空时(顶层非对象/数组)用 <root>
out.put(prefix.isEmpty() ? "<root>" : prefix, node.isNull() ? "null" : node.asText());
}
}
/** null 安全转空串 */
private static String nz(String s) {
return s == null ? "" : s;
}
}

View File

@ -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 且权限串正确。
*
* <p>「权限门挡住无权写」是声明式的:@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 读)");
}
}

View File

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

View File

@ -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 库里跑 ① <b>V1..V29 全量迁移按版本序真库全应用</b>(Flyway replay 全绿,多语句交 MySQL 服务端解析),
* ② <b>V29 配置治理两表的存取行为</b>:定版单表写、历史列表、任一版取回、30KB/64KB 边界 prompt 无截断(证 MEDIUMTEXT)、
* utf8mb4 字节口径(content_bytes 按字节非字符 + 含中文/emoji round-trip)、uk_set_version 幂等去重。
*
* <p><b>覆盖边界</b>:GenConfigService 的分支逻辑(状态机/定版事务/diff)由 {@link GenConfigServiceImplTest} 单测坐实,
* 本 IT 用真 SQL 复演其存储序列、验真库口径。全程 throwaway 库 {@code aigc_config_gov_e2e} 建/删,隔离、不碰真库真表。
*
* <p><b>默认跳过</b>:{@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<major>.<minor>.<patch>__ */
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<Path> 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<Integer> 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<Path> listMigrationsSorted() throws IOException {
List<Path> 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<Integer> listVersionsDesc(Connection conn, long configSetId) throws SQLException {
List<Integer> 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);
}
}
}

View File

@ -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<GenConfigSetDO> 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<GenConfigSetDO> 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<GenConfigVersionDO> 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<GenConfigSetDO> 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<GenConfigVersionDO> 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<GenConfigVersionDO> 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<GenConfigSetDO> 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<GenConfigSetDO> 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<GenConfigSetDO> 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<GenConfigVersionDiffRespVO.KnobChange> 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;
}
}

View File

@ -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;配置控制面阶段二)';