From f9b85d429ba6deb4302207d421fd1683cd55a47f Mon Sep 17 00:00:00 2001 From: lili Date: Fri, 3 Jul 2026 18:51:08 -0700 Subject: [PATCH] =?UTF-8?q?fix(studio):=20=E9=80=8F=E4=BC=A0=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=9D=A2=E4=B8=89=E6=8B=92=E7=BB=9D=E7=A0=81(11010010?= =?UTF-8?q?01/002/004)=E4=BF=9D=E7=95=99=E4=BA=A7=E5=93=81=E8=AF=AD?= =?UTF-8?q?=E4=B9=89,=E5=85=B6=E4=BD=99=E5=BC=82=E5=B8=B8=E4=BB=8D?= =?UTF-8?q?=E5=8C=85=E8=A3=B9=201112002000=20(cutover=20S2=20=E5=B7=A5?= =?UTF-8?q?=E5=8D=95d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../service/studio/StudioServiceImpl.java | 62 +++++++++++++ .../service/studio/StudioServiceImplTest.java | 87 +++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioServiceImpl.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioServiceImpl.java index fa87c144..f5650440 100644 --- a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioServiceImpl.java +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioServiceImpl.java @@ -25,6 +25,7 @@ import com.wanxiang.huijing.game.module.aigc.dto.AigcTemplateDTO; import com.wanxiang.huijing.game.module.aigc.enums.AigcTaskStatusEnum; import com.wanxiang.huijing.game.module.project.api.ProjectApi; import com.wanxiang.huijing.module.system.api.passport.PlayerApi; +import com.wanxiang.huijing.framework.common.exception.ServiceException; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +40,9 @@ import java.util.Objects; import java.util.Set; import static com.wanxiang.huijing.game.module.studio.enums.ErrorCodeConstants.*; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_BACKPRESSURE_REJECTED; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_QUOTA_EXCEEDED; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_UNSAFE_PROMPT; import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; /** @@ -59,6 +63,16 @@ public class StudioServiceImpl implements StudioService { private static final Logger log = LoggerFactory.getLogger(StudioServiceImpl.class); + /** + * 控制面面向用户的类型化拒绝码集合(aigc 段 1-101-001-***:配额超限 / 背压拒绝 / Prompt 不安全)。 + * 委托 aigc 抛出这些码时原样透传给创作者、保留产品语义,不一刀切包成 STUDIO_AIGC_DELEGATE_FAIL + * (2026-07-04 cutover S2 实证:GP9 拒绝/并发位满时创作者只见笼统「生成委托失败」,看不到真实原因)。 + */ + private static final Set AIGC_USER_REJECT_CODES = Set.of( + AIGC_QUOTA_EXCEEDED.getCode(), + AIGC_BACKPRESSURE_REJECTED.getCode(), + AIGC_UNSAFE_PROMPT.getCode()); + @Resource private StudioSessionMapper studioSessionMapper; @@ -166,6 +180,19 @@ public class StudioServiceImpl implements StudioService { AigcGenerateRespDTO resp; try { resp = aigcApi.submitGenerate(req).getCheckedData(); + } catch (ServiceException se) { + // 委托 aigc 抛业务异常:先置链态失败(保持链态语义不变,与原一刀切路径一致)。 + markChainFailed(chain, session); + if (isControlPlaneUserReject(se)) { + // 控制面面向用户的类型化拒绝(配额/背压/不安全 prompt)原样透传、保留产品语义, + // 不包成 STUDIO_AIGC_DELEGATE_FAIL,创作者可见真实拒绝原因。 + log.warn("[generate] aigc 控制面拒绝透传 code={} sessionId={} userId={}", + se.getCode(), session.getId(), userId); + throw se; + } + // 其余业务异常按原逻辑包成 studio 委托失败码(行为一字不变)。 + log.error("[generate] 生成委托 aigc 失败 sessionId={} userId={}", session.getId(), userId, se); + throw exception(STUDIO_AIGC_DELEGATE_FAIL); } catch (Exception e) { // aigc 调用异常 → 链态置失败 3 + 会话失败,抛 studio 错误码(中文日志,traceId 由 aigc 内部生成此处暂无) markChainFailed(chain, session); @@ -241,6 +268,18 @@ public class StudioServiceImpl implements StudioService { AigcGenerateRespDTO resp; try { resp = aigcApi.retryTask(origin.getAigcTaskId(), userId).getCheckedData(); + } catch (ServiceException se) { + if (isControlPlaneUserReject(se)) { + // 控制面面向用户的类型化拒绝(配额/背压/不安全 prompt)原样透传、保留产品语义; + // regenerate 原路径无链态清理(不 markChainFailed),透传亦保持原清理动作=无。 + log.warn("[regenerate] aigc 控制面拒绝透传 code={} sessionId={} taskChainId={} aigcTaskId={} userId={}", + se.getCode(), origin.getSessionId(), taskChainId, origin.getAigcTaskId(), userId); + throw se; + } + // 其余业务异常按原逻辑包成 studio 委托失败码(行为一字不变)。 + log.error("[regenerate] 重生成委托 aigc 失败 taskChainId={} aigcTaskId={} userId={}", + taskChainId, origin.getAigcTaskId(), userId, se); + throw exception(STUDIO_AIGC_DELEGATE_FAIL); } catch (Exception e) { log.error("[regenerate] 重生成委托 aigc 失败 taskChainId={} aigcTaskId={} userId={}", taskChainId, origin.getAigcTaskId(), userId, e); @@ -406,6 +445,18 @@ public class StudioServiceImpl implements StudioService { AigcGenerateRespDTO resp; try { resp = aigcApi.submitGenerate(req).getCheckedData(); + } catch (ServiceException se) { + // 委托 aigc 抛业务异常:先置链态失败(保持链态语义不变)。 + markChainFailed(chain, session); + if (isControlPlaneUserReject(se)) { + // 控制面面向用户的类型化拒绝(配额/背压/不安全 prompt)原样透传、保留产品语义; + // create/modify/extend 三路共用本 helper,一致透传,不包成 STUDIO_AIGC_DELEGATE_FAIL。 + log.warn("[{}] aigc 控制面拒绝透传 code={} sessionId={} userId={}", + opLabel, se.getCode(), session.getId(), req.getUserId()); + throw se; + } + log.error("[{}] 生成委托 aigc 失败 sessionId={} userId={}", opLabel, session.getId(), req.getUserId(), se); + throw exception(STUDIO_AIGC_DELEGATE_FAIL); } catch (Exception e) { markChainFailed(chain, session); log.error("[{}] 生成委托 aigc 失败 sessionId={} userId={}", opLabel, session.getId(), req.getUserId(), e); @@ -695,6 +746,17 @@ public class StudioServiceImpl implements StudioService { updateSessionStatus(session.getId(), StudioSessionStatusEnum.FAILED.getStatus()); } + /** + * 判定 aigc 委托抛出的业务异常是否为控制面面向用户的类型化拒绝(配额/背压/不安全 prompt)。 + * 命中则由调用方原样透传保留产品语义,否则按原逻辑包成 STUDIO_AIGC_DELEGATE_FAIL。 + * + * @param se aigc 委托抛出的业务异常 + * @return true=需透传(命中拒绝码集合);false=按原逻辑包裹 + */ + private boolean isControlPlaneUserReject(ServiceException se) { + return se.getCode() != null && AIGC_USER_REJECT_CODES.contains(se.getCode()); + } + /** * 回写会话状态(仅状态位更新,避免整对象覆盖) * diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioServiceImplTest.java b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioServiceImplTest.java index fbf66738..e177d04d 100644 --- a/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioServiceImplTest.java +++ b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioServiceImplTest.java @@ -31,6 +31,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import static com.wanxiang.huijing.game.module.studio.enums.ErrorCodeConstants.*; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_BACKPRESSURE_REJECTED; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_QUOTA_EXCEEDED; +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_UNSAFE_PROMPT; import static com.wanxiang.huijing.module.system.enums.ErrorCodeConstants.PLAYER_CREATE_NOT_IN_WHITELIST; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -234,6 +237,48 @@ class StudioServiceImplTest extends BaseMockitoUnitTest { assertEquals(STUDIO_AIGC_DELEGATE_FAIL.getCode(), ex.getCode()); } + @Test + void testGenerate_aigcControlPlaneReject_passesThrough_andMarksFailed() { + // 控制面拒绝透传(cutover S2 工单d):aigc submitGenerate 抛 1-101-001-004(Prompt 不安全)→ + // studio 原样透传该码(非包成 1-112-002-000),且链态先置失败(markChainFailed 保持链态语义不变)。 + stubCreatorPass(); + StudioSessionDO session = session(512L, USER_ID, 2048L, "idle"); + when(studioSessionMapper.selectById(512L)).thenReturn(session); + doAnswer(inv -> { ((StudioTaskChainDO) inv.getArgument(0)).setId(1000L); return 1; }) + .when(studioTaskChainMapper).insert(any(StudioTaskChainDO.class)); + // aigc 控制面同步拒绝:抛类型化 ServiceException(GP9 不安全 prompt 门④) + when(aigcApi.submitGenerate(any(AigcGenerateReqDTO.class))) + .thenThrow(new ServiceException(AIGC_UNSAFE_PROMPT.getCode(), "Prompt 不安全")); + + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.generate(generateReq(512L, "违规内容", null), USER_ID)); + + // 断言:原样透传控制面拒绝码 1-101-001-004(非 1-112-002-000) + assertEquals(AIGC_UNSAFE_PROMPT.getCode(), ex.getCode()); + // 断言:链态被置失败(透传亦保持链态语义不变) + ArgumentCaptor chainCaptor = ArgumentCaptor.forClass(StudioTaskChainDO.class); + verify(studioTaskChainMapper).updateById(chainCaptor.capture()); + assertEquals(StudioSessionStatusEnum.FAILED.getStatus(), chainCaptor.getValue().getStatus()); + } + + @Test + void testGenerate_aigcOtherServiceException_stillWrapped() { + // 非透传集合的业务异常(如 1-101-000-000 任务不存在)→ 仍按原逻辑包成 1-112-002-000(行为不变)。 + stubCreatorPass(); + StudioSessionDO session = session(512L, USER_ID, 2048L, "idle"); + when(studioSessionMapper.selectById(512L)).thenReturn(session); + doAnswer(inv -> { ((StudioTaskChainDO) inv.getArgument(0)).setId(1000L); return 1; }) + .when(studioTaskChainMapper).insert(any(StudioTaskChainDO.class)); + when(aigcApi.submitGenerate(any(AigcGenerateReqDTO.class))) + .thenThrow(new ServiceException(1_101_000_000, "生成任务不存在")); + + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.generate(generateReq(512L, "p", null), USER_ID)); + + // 断言:非控制面拒绝码的业务异常仍被包成 studio 委托失败码 + assertEquals(STUDIO_AIGC_DELEGATE_FAIL.getCode(), ex.getCode()); + } + // ============================== getTaskChain 状态流转 ============================== @Test @@ -305,6 +350,23 @@ class StudioServiceImplTest extends BaseMockitoUnitTest { verify(aigcApi).retryTask(8888L, USER_ID); } + @Test + void testRegenerate_aigcControlPlaneReject_passesThrough() { + // 控制面拒绝透传(cutover S2 工单d):regenerate 委托 retryTask 抛 1-101-001-001(配额/并发满)→ + // 原样透传该码(非包成 1-112-002-000)。regenerate 原路径无链态清理,透传亦不引入清理(保持原语义)。 + StudioTaskChainDO origin = taskChain(1000L, 512L, USER_ID, 8888L); + when(studioTaskChainMapper.selectById(1000L)).thenReturn(origin); + when(aigcApi.retryTask(8888L, USER_ID)) + .thenThrow(new ServiceException(AIGC_QUOTA_EXCEEDED.getCode(), "今日生成额度已用完或并发已满,请稍后再试")); + + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.regenerate(1000L, USER_ID)); + + assertEquals(AIGC_QUOTA_EXCEEDED.getCode(), ex.getCode()); + // regenerate 无 markChainFailed:透传不应触碰任务链 updateById(保持原清理动作=无) + verify(studioTaskChainMapper, never()).updateById(any(StudioTaskChainDO.class)); + } + @Test void testGetTaskChain_taskChainNotExists_throws() { when(studioTaskChainMapper.selectById(1000L)).thenReturn(null); @@ -398,6 +460,31 @@ class StudioServiceImplTest extends BaseMockitoUnitTest { verify(aigcApi, never()).submitGenerate(any()); } + @Test + void testCreate_aigcControlPlaneReject_passesThrough_viaHelper() { + // 控制面拒绝透传(共享 helper delegateGenerate 路,create/modify/extend 一致):create 委托 submitGenerate 抛 + // 1-101-001-002(背压)→ 原样透传该码(非包成 1-112-002-000),且链态先置失败。 + stubCreatorPass(); + when(projectApi.createProject(eq(USER_ID), eq("我的小游戏"), eq("generic"))) + .thenReturn(CommonResult.success(2048L)); + doAnswer(inv -> { ((StudioSessionDO) inv.getArgument(0)).setId(512L); return 1; }) + .when(studioSessionMapper).insert(any(StudioSessionDO.class)); + doAnswer(inv -> { ((StudioTaskChainDO) inv.getArgument(0)).setId(1000L); return 1; }) + .when(studioTaskChainMapper).insert(any(StudioTaskChainDO.class)); + when(aigcApi.submitGenerate(any(AigcGenerateReqDTO.class))) + .thenThrow(new ServiceException(AIGC_BACKPRESSURE_REJECTED.getCode(), "生成排队过多,请稍后再试")); + + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.create(createReq("我的小游戏", "做一个躲避陨石的小游戏", "generic"), USER_ID)); + + // 断言:helper 路同样透传控制面拒绝码 1-101-001-002(非 1-112-002-000) + assertEquals(AIGC_BACKPRESSURE_REJECTED.getCode(), ex.getCode()); + // 断言:链态被置失败(delegateGenerate markChainFailed) + ArgumentCaptor chainCaptor = ArgumentCaptor.forClass(StudioTaskChainDO.class); + verify(studioTaskChainMapper).updateById(chainCaptor.capture()); + assertEquals(StudioSessionStatusEnum.FAILED.getStatus(), chainCaptor.getValue().getStatus()); + } + // ============================== 生命周期编排 modify(C3)============================== @Test