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); + } + + /** + * 判断从当前状态到目标状态是否为合法流转(状态机唯一裁决处,服务层据此拒非法流转)。 + * + *

合法边(对齐类注释状态图): + *

+ * 归档态(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;配置控制面阶段二)';