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:
parent
870bf56bfb
commit
7534bdf9b7
@ -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;可选,缺省=generic:W-CLEAN 废玩法模板层后,一句话创作无需选模板,入口将缺省 templateId 归一为 generic 单一通用口径,品类区分留 prompt 层;前端亦可显式从模板列表选)' }
|
||||
gameId: { type: integer, format: int64, description: '关联游戏 ID(project.game_project.id)。产物 GamePackage 经此落 game_version;studio 草稿场景传入' }
|
||||
versionId: { type: integer, format: int64, description: '关联版本 ID(project.game_version.id)。指定则产物写入该版本;为空时由生成流程新建版本' }
|
||||
attachments:
|
||||
|
||||
@ -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(可选,缺省 generic:W-CLEAN 废玩法模板层后建项目无需选模板,入口将空 templateId 归一为 generic 单一通用口径)' }
|
||||
ProjectDraftReqVO:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@ -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"(恰好命中白名单),
|
||||
// 故此处去掉 @NotBlank、requiredMode 由 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 = "关联游戏 ID(project.game_project.id);studio 草稿场景传入", example = "1024")
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -96,7 +96,9 @@ public interface ProjectApi {
|
||||
@Operation(summary = "创建项目(studio 写类 RPC,owner=显式 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 桥接(契约面一致避免 drift):templateId 改可选——空值由 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);
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
@ -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"); // 默认全年龄,发布前可改
|
||||
|
||||
@ -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 废玩法模板层后建草稿无需选模板,契约面一致避免 drift):templateId 改可选;
|
||||
// 缺省值由服务层入口 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 = "做一个躲避陨石的小游戏")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)==============================
|
||||
|
||||
@ -20,12 +20,12 @@ export function getTemplates(): Promise<AigcTemplate[]> {
|
||||
* 提交生成任务(异步入队,立即返回 taskId)。
|
||||
* POST /app-api/aigc/generate
|
||||
* @param prompt 一句话自然语言 Prompt
|
||||
* @param templateId 玩法模板 ID(可空)
|
||||
* @param templateId 玩法模板 ID(可选,缺省 generic:W-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 }
|
||||
|
||||
@ -141,8 +141,8 @@ export type AigcFailureReason =
|
||||
export interface AigcGenerateReq {
|
||||
/** 一句话自然语言 Prompt(1-1000 字) */
|
||||
prompt: string
|
||||
/** 玩法模板 ID(可空,空则由 Dify 意图解析决定) */
|
||||
templateId: string
|
||||
/** 玩法模板 ID(可选,缺省 generic:W-CLEAN 废玩法模板层后由后端归一,前端可不传或传 'generic') */
|
||||
templateId?: string
|
||||
/** 关联游戏 ID(studio 草稿场景传入) */
|
||||
gameId?: number
|
||||
/** 关联版本 ID(指定则写入该版本,空则新建版本) */
|
||||
@ -294,8 +294,8 @@ export interface Project {
|
||||
export interface ProjectCreateReq {
|
||||
/** 标题(≤60) */
|
||||
title: string
|
||||
/** 玩法模板 ID */
|
||||
templateId: string
|
||||
/** 玩法模板 ID(可选,缺省 generic:W-CLEAN 废玩法模板层后由后端归一,前端可不传或传 'generic') */
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
/** 保存草稿请求体(project.yaml ProjectDraftReqVO) */
|
||||
|
||||
@ -75,12 +75,12 @@ export const useCreateStore = defineStore('create', () => {
|
||||
/**
|
||||
* 提交生成任务并自动开始轮询,返回 taskId(供路由跳转 /create/task/:taskId)。
|
||||
* @param prompt 一句话 Prompt
|
||||
* @param templateId 玩法模板 ID(可空)
|
||||
* @param templateId 玩法模板 ID(可选,缺省 generic:W-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)
|
||||
|
||||
@ -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=projectId);store 内部已自动开始轮询。
|
||||
// 契约 AigcGenerateReqVO:prompt + templateId(+ gameId 关联草稿)。
|
||||
const taskId = await createStore.generate(prompt.value.trim(), selectedTemplateId.value, {
|
||||
// 契约 AigcGenerateReqVO:prompt(+ gameId 关联草稿;templateId 可选,缺省 generic)。
|
||||
const taskId = await createStore.generate(prompt.value.trim(), templateId, {
|
||||
gameId: projectId,
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user