feat(create): generic 桥接——一句话创作无需选模板即可生成(入口归一缺省 templateId=generic)

背景:W-CLEAN 废玩法模板层后 template/list 返空,创作页「开始生成」因 canSubmit 依赖
「必须选中模板」而恒禁用;后端 @NotBlank/同源白名单/派发分支/落包一致性四道硬约束又要求
templateId 必填非空。核心思想=在入口把缺省 templateId 归一为 "generic"(恰好命中
SUPPORTED_TEMPLATE_IDS=[generic] 单一通用口径,品类区分留 prompt 层),四道约束以「恰好
generic」全部一致通过,DB 零改(template_id NOT NULL DEFAULT '' 归一后落 generic)、后端业务零改。

契约层(2):aigc.yaml AigcGenerateReqVO required 去 templateId、project.yaml
  ProjectCreateReqVO required 去 templateId,templateId 描述标注「可选,缺省 generic」。
后端层(归一 generic):
  - AigcGenerateReqVO/ProjectCreateReqVO/StudioDraftCreateReqVO 去 templateId @NotBlank +
    requiredMode REQUIRED→NOT_REQUIRED;
  - AigcTaskServiceImpl.submitGenerate 在 validateTemplateExists 前归一(validateTemplateExists 不改);
  - ProjectServiceImpl.createProject(BeanUtils.toBean 后)归一;
  - StudioServiceImpl.createDraft 原「空 templateId 抛 STUDIO_TEMPLATE_REQUIRED」改为归一 generic;
  - ProjectApi.createProject 的 templateId @Parameter required 放开(契约面一致避免 drift)。
前端 game-studio(去模板门):types.ts 两请求体 templateId 改可选;store/create.ts、api/aigc.ts
  generate 入参改可选;Create.vue canSubmit 去「必须选中模板」条件(prompt 非空即可提交)、
  onSubmit 缺省传 'generic'(空态下也能提交)。
测试校正:StudioServiceImplTest「空模板抛错」→「空模板归一 generic 透传 project」(行为变更)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-17 04:05:39 +00:00
parent 870bf56bfb
commit 7534bdf9b7
14 changed files with 80 additions and 36 deletions

View File

@ -197,10 +197,10 @@ components:
# ---- 请求 VO ----
AigcGenerateReqVO:
type: object
required: [prompt, templateId]
required: [prompt]
properties:
prompt: { type: string, minLength: 1, maxLength: 1000, description: 创作者一句话自然语言 Prompt→ 契约#6 input.prompt }
templateId: { type: string, description: '玩法模板 ID→ 契约#6 input.templateId由前端从模板列表选,空则由 Dify 意图解析+模板匹配决定' }
templateId: { type: string, description: '玩法模板 ID→ 契约#6 input.templateId选,缺省=genericW-CLEAN 废玩法模板层后,一句话创作无需选模板,入口将缺省 templateId 归一为 generic 单一通用口径,品类区分留 prompt 层;前端亦可显式从模板列表选' }
gameId: { type: integer, format: int64, description: '关联游戏 IDproject.game_project.id。产物 GamePackage 经此落 game_versionstudio 草稿场景传入' }
versionId: { type: integer, format: int64, description: '关联版本 IDproject.game_version.id。指定则产物写入该版本为空时由生成流程新建版本' }
attachments:

View File

@ -168,10 +168,10 @@ components:
# ---- 请求/响应 VO ----
ProjectCreateReqVO:
type: object
required: [title, templateId]
required: [title]
properties:
title: { type: string, maxLength: 60, description: 标题 }
templateId: { type: string, description: 玩法模板 ID }
templateId: { type: string, description: '玩法模板 ID可选缺省 genericW-CLEAN 废玩法模板层后建项目无需选模板,入口将空 templateId 归一为 generic 单一通用口径)' }
ProjectDraftReqVO:
type: object
properties:

View File

@ -22,9 +22,11 @@ public class AigcGenerateReqVO {
@Size(max = 1000, message = "Prompt 不能超过 1000 个字符")
private String prompt;
@Schema(description = "玩法模板 ID→ 契约#6 input.templateId空则由 Dify 意图解析匹配)",
requiredMode = Schema.RequiredMode.REQUIRED, example = "dodge")
@NotBlank(message = "玩法模板不能为空")
// generic 桥接W-CLEAN 废玩法模板层后一句话创作无需选模板templateId 改可选
// 缺省值由服务层入口 AigcTaskServiceImpl.submitGenerate 归一为 "generic"恰好命中白名单
// 故此处去掉 @NotBlankrequiredMode REQUIRED NOT_REQUIRED契约 aigc.yaml AigcGenerateReqVO required 同步去掉 templateId
@Schema(description = "玩法模板 ID→ 契约#6 input.templateId可选缺省=generic由服务层归一",
requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "generic")
private String templateId;
@Schema(description = "关联游戏 IDproject.game_project.idstudio 草稿场景传入", example = "1024")

View File

@ -104,6 +104,12 @@ public class AigcTaskServiceImpl implements AigcTaskService {
if (!StringUtils.hasText(reqVO.getPrompt())) {
throw exception(AIGC_PROMPT_REQUIRED);
}
// generic 桥接W-CLEAN 废玩法模板层后一句话创作无需选模板缺省 templateId 归一为 "generic"
// 单一通用口径品类区分留 prompt 归一后恰好命中下方 validateTemplateExists 的同源白名单
// SUPPORTED_TEMPLATE_IDS=[generic]@NotBlank 已下放四道硬约束以恰好 generic一致通过validateTemplateExists 本身不改
if (!StringUtils.hasText(reqVO.getTemplateId())) {
reqVO.setTemplateId("generic");
}
// 模板存在性校验
validateTemplateExists(reqVO.getTemplateId());

View File

@ -96,7 +96,9 @@ public interface ProjectApi {
@Operation(summary = "创建项目studio 写类 RPCowner=显式 creatorUserId")
@Parameter(name = "creatorUserId", description = "创作者用户 ID项目 owner", required = true, example = "99")
@Parameter(name = "title", description = "标题", required = true, example = "我的放置小镇")
@Parameter(name = "templateId", description = "玩法模板 ID", required = true, example = "idle")
// generic 桥接契约面一致避免 drifttemplateId 改可选空值由 project createProject 归一 generic
// @RequestParam 不加 required=false 保持现状调用方 studio 已归一后再传恒非空仅文档 @Parameter required 放开
@Parameter(name = "templateId", description = "玩法模板 ID可选缺省 generic", required = false, example = "generic")
CommonResult<Long> createProject(@RequestParam("creatorUserId") Long creatorUserId,
@RequestParam("title") String title,
@RequestParam("templateId") String templateId);

View File

@ -19,8 +19,11 @@ public class ProjectCreateReqVO {
@Size(max = 60, message = "标题不能超过 60 个字符")
private String title;
@Schema(description = "玩法模板 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "idle")
@NotBlank(message = "玩法模板不能为空")
// generic 桥接W-CLEAN 废玩法模板层后建项目无需选模板templateId 改可选
// 缺省值由服务层入口 ProjectServiceImpl.createProject BeanUtils.toBean 后归一为 "generic"
// DB template_id NOT NULL DEFAULT ''归一后落 generic契约 project.yaml ProjectCreateReqVO required 同步去掉 templateId
@Schema(description = "玩法模板 ID可选缺省 generic由服务层归一",
requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "generic")
private String templateId;
}

View File

@ -103,6 +103,11 @@ public class ProjectServiceImpl implements ProjectService {
playerApi.validateCreator(userId).getCheckedData();
// 拷贝 title/templateId其余服务端字段显式置默认值
ProjectDO project = BeanUtils.toBean(reqVO, ProjectDO.class);
// generic 桥接W-CLEAN 废玩法模板层后建项目无需选模板缺省 templateId 归一为 "generic"
// 单一通用口径DB template_id NOT NULL DEFAULT ''归一后落 generic@NotBlank 已下放本归一兜服务端
if (!StringUtils.hasText(project.getTemplateId())) {
project.setTemplateId("generic");
}
project.setCreatorUserId(userId);
project.setStatus(ProjectStatusEnum.DRAFT.getStatus()); // 初始草稿
project.setAgeRating("all"); // 默认全年龄发布前可改

View File

@ -22,8 +22,10 @@ public class StudioDraftCreateReqVO {
@Size(max = 128, message = "标题不能超过 128 个字符")
private String title;
@Schema(description = "玩法模板 ID透传 project/aigc", requiredMode = Schema.RequiredMode.REQUIRED, example = "idle")
@NotBlank(message = "玩法模板不能为空")
// generic 桥接W-CLEAN 废玩法模板层后建草稿无需选模板契约面一致避免 drifttemplateId 改可选
// 缺省值由服务层入口 StudioServiceImpl.createDraft 归一为 "generic" 后透传 project/aigc恰好命中白名单
@Schema(description = "玩法模板 ID透传 project/aigc可选缺省=generic由服务层归一",
requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "generic")
private String templateId;
@Schema(description = "一句话自然语言 Prompt建草稿时可空generate 时必填)", example = "做一个躲避陨石的小游戏")

View File

@ -83,9 +83,11 @@ public class StudioServiceImpl implements StudioService {
// 创作者白名单可信边界校验2026-06-10 鉴权件 §6.3拍板4 A2 白名单创作者直接拒 1-002-090-003
// 置于建草稿最前避免非白名单用户触达后续 project 建游戏副作用校验失败由 .getCheckedData() 触发异常向上传播
playerApi.validateCreator(userId).getCheckedData();
// 模板必填兜底校验VO @NotBlank此处服务端兜底
// generic 桥接W-CLEAN 废玩法模板层后建草稿无需选模板缺省 templateId 归一为 "generic"
// 单一通用口径品类区分留 prompt 再透传 project/aigc恰好命中 aigc 同源白名单 SUPPORTED_TEMPLATE_IDS=[generic]
// templateId STUDIO_TEMPLATE_REQUIRED行为已改为归一 generic不再拒空
if (!StringUtils.hasText(reqVO.getTemplateId())) {
throw exception(STUDIO_TEMPLATE_REQUIRED);
reqVO.setTemplateId("generic");
}
// R2 硬前置 project createProject 建游戏拿 gameId不接受 game_id 恒空兜底显式传 creatorUserId owner
Long gameId;

View File

@ -108,13 +108,27 @@ class StudioServiceImplTest extends BaseMockitoUnitTest {
}
@Test
void testCreateDraft_templateRequired_throws() {
stubCreatorPass(); // 白名单先过校验在模板兜底之前再触发模板为空兜底
// 模板为空 服务端兜底校验抛错不调 project
ServiceException ex = assertThrows(ServiceException.class,
() -> studioService.createDraft(draftReq("t", ""), USER_ID));
assertEquals(STUDIO_TEMPLATE_REQUIRED.getCode(), ex.getCode());
verify(projectApi, never()).createProject(any(), any(), any());
void testCreateDraft_emptyTemplate_normalizedToGeneric() {
// generic 桥接行为变更 templateId STUDIO_TEMPLATE_REQUIRED已改为归一 "generic" 后透传 project
// 本用例校正为新不变量 templateId 归一 generic generic project createProject 并落会话
stubCreatorPass(); // 白名单放行§6.3
when(projectApi.createProject(eq(USER_ID), eq("t"), eq("generic")))
.thenReturn(CommonResult.success(2048L));
doAnswer(inv -> {
StudioSessionDO d = inv.getArgument(0);
d.setId(512L);
return 1;
}).when(studioSessionMapper).insert(any(StudioSessionDO.class));
StudioSessionDO session = studioService.createDraft(draftReq("t", ""), USER_ID);
// 断言空模板被归一 generic 透传 project且会话 templateId generic
assertEquals(2048L, session.getGameId());
assertEquals("generic", session.getTemplateId());
verify(projectApi).createProject(USER_ID, "t", "generic");
ArgumentCaptor<StudioSessionDO> captor = ArgumentCaptor.forClass(StudioSessionDO.class);
verify(studioSessionMapper).insert(captor.capture());
assertEquals("generic", captor.getValue().getTemplateId());
}
// ============================== 创作者白名单校验2026-06-10 鉴权件 §6.3拍板4==============================

View File

@ -20,12 +20,12 @@ export function getTemplates(): Promise<AigcTemplate[]> {
* taskId
* POST /app-api/aigc/generate
* @param prompt Prompt
* @param templateId ID
* @param templateId ID genericW-CLEAN
* @param extra 稿gameId/versionId/attachments
*/
export function generate(
prompt: string,
templateId: string,
templateId?: string,
extra?: Pick<AigcGenerateReq, 'gameId' | 'versionId' | 'attachments'>,
): Promise<GenerateResp> {
const body: AigcGenerateReq = { prompt, templateId, ...extra }

View File

@ -141,8 +141,8 @@ export type AigcFailureReason =
export interface AigcGenerateReq {
/** 一句话自然语言 Prompt1-1000 字) */
prompt: string
/** 玩法模板 ID空,空则由 Dify 意图解析决定 */
templateId: string
/** 玩法模板 ID选,缺省 genericW-CLEAN 废玩法模板层后由后端归一,前端可不传或传 'generic' */
templateId?: string
/** 关联游戏 IDstudio 草稿场景传入) */
gameId?: number
/** 关联版本 ID指定则写入该版本空则新建版本 */
@ -294,8 +294,8 @@ export interface Project {
export interface ProjectCreateReq {
/** 标题≤60 */
title: string
/** 玩法模板 ID */
templateId: string
/** 玩法模板 ID(可选,缺省 genericW-CLEAN 废玩法模板层后由后端归一,前端可不传或传 'generic' */
templateId?: string
}
/** 保存草稿请求体project.yaml ProjectDraftReqVO */

View File

@ -75,12 +75,12 @@ export const useCreateStore = defineStore('create', () => {
/**
* taskId /create/task/:taskId
* @param prompt Prompt
* @param templateId ID
* @param templateId ID genericW-CLEAN
* @param extra 稿gameId/versionId
*/
async function generate(
prompt: string,
templateId: string,
templateId?: string,
extra?: { gameId?: number; versionId?: number },
): Promise<number> {
const resp = await aigcApi.generate(prompt, templateId, extra)

View File

@ -49,9 +49,13 @@ const selectedTemplate = computed<AigcTemplate | undefined>(() =>
createStore.templates.find((t) => t.templateId === selectedTemplateId.value),
)
/** 是否可提交:选了模板 + Prompt 非空 + 不在提交中 */
/**
* 是否可提交Prompt 非空 + 不在提交中
* generic 桥接W-CLEAN 废玩法模板层后一句话创作无需选模板去掉必须选中模板条件
* 后端缺省 templateId 归一 generic故空模板template/list 返空走升级中空态下也能提交
*/
const canSubmit = computed(
() => !!selectedTemplateId.value && prompt.value.trim().length > 0 && !submitting.value,
() => prompt.value.trim().length > 0 && !submitting.value,
)
/** 加载模板列表(进入页面 / 失败重试时调用) */
@ -101,17 +105,21 @@ async function onSubmit() {
if (!canSubmit.value) return
submitting.value = true
try {
// 1) 稿title Prompt 30
// ProjectCreateReqVO required:[title, templateId]
// generic W-CLEAN templateId 'generic'
// template/list
const templateId = selectedTemplateId.value || 'generic'
// 1) 稿title Prompt 30
// ProjectCreateReqVO required:[title]templateId generic
const title = prompt.value.trim().slice(0, 30) || selectedTemplate.value?.name || '未命名作品'
const projectId = await projectStore.create({
title,
templateId: selectedTemplateId.value,
templateId,
})
// 2) 稿gameId=projectIdstore
// AigcGenerateReqVOprompt + templateId+ gameId 稿
const taskId = await createStore.generate(prompt.value.trim(), selectedTemplateId.value, {
// AigcGenerateReqVOprompt+ gameId 稿templateId generic
const taskId = await createStore.generate(prompt.value.trim(), templateId, {
gameId: projectId,
})