From b70e90b7754e2ae9c723b424d6de4696dde0bc58 Mon Sep 17 00:00:00 2001 From: zizi Date: Tue, 9 Jun 2026 02:15:20 +0000 Subject: [PATCH] =?UTF-8?q?feat(wave3-phaseB):=20compliance/studio=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=20+=20contractGaps(=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=85=A8=E7=BB=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B 并行建(4 路互斥目录),主 agent 合并 reactor 验证: - compliance(109/V9):ComplianceGateApi.evaluate seam + 风格原子桩(StyleComplianceAtom/IpComplianceSeam) + 分级 + 封禁(R3 状态分支,下架联动留 Phase C)+ V9 DDL(gate_result/content_rating/user_ban,不建审核状态机表 D2) - studio(112/V8):创作任务链(createDraft→project.createProject / generate→aigc)+ attachments 持久化-only + 新增 AigcApi seam(additive)+ V8 DDL - contractGaps 后端:GAP-3 feed ShareMeta versionId(调 getCurrentVersionId 仅已发布)、GAP-2 runtime manifestUrl(纯派生零DDL) - contractGaps 前端 game-studio:GAP-1 publish admitted/gates、GAP-3 share→play versionId、GAP-2 取包 manifestUrl+sha256 - 合并验证:全 53 模块 reactor 编译 SUCCESS;新/改模块单测全绿(compliance8/studio11/feed12/runtime24) - 注:前端 game-studio build 待 mini-desktop 验;Phase C 待接入 project.publish→ComplianceGateApi Co-Authored-By: Claude Opus 4.8 --- contracts/api-schemas/compliance.yaml | 218 +++++++++++++ contracts/api-schemas/feed.yaml | 1 + contracts/api-schemas/runtime.yaml | 14 +- contracts/api-schemas/studio.yaml | 186 +++++++++++ .../db-schemas/V8.0.0__create_game_studio.sql | 92 ++++++ .../V9.0.0__create_game_compliance.sql | 84 +++++ .../game/module/aigc/api/AigcApi.java | 96 ++++++ .../module/aigc/dto/AigcGenerateReqDTO.java | 39 +++ .../module/aigc/dto/AigcGenerateRespDTO.java | 28 ++ .../game/module/aigc/dto/AigcTaskRespDTO.java | 42 +++ .../game/module/aigc/dto/AigcTemplateDTO.java | 38 +++ .../game/module/aigc/enums/ApiConstants.java | 34 ++ .../game/module/aigc/api/AigcApiImpl.java | 102 ++++++ .../game-module-compliance-api/pom.xml | 48 +++ .../compliance/api/ComplianceGateApi.java | 45 +++ .../compliance/dto/ComplianceGateItemDTO.java | 29 ++ .../compliance/dto/ComplianceGateReqDTO.java | 41 +++ .../module/compliance/dto/GateVerdict.java | 32 ++ .../module/compliance/enums/ApiConstants.java | 33 ++ .../compliance/enums/BanStatusEnum.java | 37 +++ .../compliance/enums/BanTargetTypeEnum.java | 48 +++ .../enums/ComplianceVerdictEnum.java | 70 ++++ .../compliance/enums/ErrorCodeConstants.java | 34 ++ .../game-module-compliance-server/pom.xml | 87 +++++ .../compliance/api/ComplianceGateApiImpl.java | 35 ++ .../admin/ComplianceBanController.java | 66 ++++ .../controller/admin/vo/BanCancelReqVO.java | 22 ++ .../controller/admin/vo/BanCreateReqVO.java | 32 ++ .../controller/admin/vo/BanPageReqVO.java | 26 ++ .../controller/admin/vo/UserBanRespVO.java | 38 +++ .../app/ComplianceRatingController.java | 47 +++ .../app/vo/ContentRatingRespVO.java | 28 ++ .../compliance/convert/ComplianceConvert.java | 55 ++++ .../dal/dataobject/ban/UserBanDO.java | 51 +++ .../dal/dataobject/gate/GateResultDO.java | 53 +++ .../dataobject/rating/ContentRatingDO.java | 45 +++ .../dal/mysql/ContentRatingMapper.java | 31 ++ .../dal/mysql/GateResultMapper.java | 17 + .../compliance/dal/mysql/UserBanMapper.java | 52 +++ .../framework/atom/ComplianceAtomResult.java | 41 +++ .../framework/atom/IpComplianceSeam.java | 31 ++ .../framework/atom/StyleComplianceAtom.java | 32 ++ .../service/ban/UserBanService.java | 44 +++ .../service/ban/UserBanServiceImpl.java | 114 +++++++ .../service/gate/ComplianceGateService.java | 27 ++ .../gate/ComplianceGateServiceImpl.java | 118 +++++++ .../service/gate/ContentRatingService.java | 23 ++ .../gate/ContentRatingServiceImpl.java | 31 ++ .../V9.0.0__create_game_compliance.sql | 84 +++++ .../service/ban/UserBanServiceImplTest.java | 151 +++++++++ .../gate/ComplianceGateServiceImplTest.java | 116 +++++++ game-cloud/game-module-compliance/pom.xml | 27 ++ .../game-module-feed-server/pom.xml | 7 + .../app/feed/vo/ShareMetaRespVO.java | 3 + .../feed/service/feed/FeedServiceImpl.java | 35 +- .../service/feed/FeedServiceImplTest.java | 26 +- .../app/runtime/vo/RuntimePackageRespVO.java | 4 + .../convert/runtime/RuntimeConvert.java | 27 ++ .../convert/runtime/RuntimeConvertTest.java | 77 +++++ .../game-module-studio-api/pom.xml | 49 +++ .../module/studio/enums/ApiConstants.java | 32 ++ .../studio/enums/ErrorCodeConstants.java | 34 ++ .../studio/enums/StudioSessionStatusEnum.java | 44 +++ .../game-module-studio-server/pom.xml | 95 ++++++ .../app/studio/AppStudioController.java | 84 +++++ .../app/studio/vo/StudioDraftCreateReqVO.java | 36 +++ .../app/studio/vo/StudioGenerateReqVO.java | 33 ++ .../app/studio/vo/StudioSessionRespVO.java | 33 ++ .../app/studio/vo/StudioTaskChainRespVO.java | 41 +++ .../studio/convert/studio/StudioConvert.java | 57 ++++ .../dal/dataobject/studio/StudioAssetDO.java | 44 +++ .../dataobject/studio/StudioSessionDO.java | 58 ++++ .../dataobject/studio/StudioTaskChainDO.java | 62 ++++ .../dal/mysql/studio/StudioAssetMapper.java | 33 ++ .../dal/mysql/studio/StudioSessionMapper.java | 33 ++ .../mysql/studio/StudioTaskChainMapper.java | 42 +++ .../studio/service/studio/StudioService.java | 77 +++++ .../service/studio/StudioServiceImpl.java | 303 ++++++++++++++++++ .../migration/V8.0.0__create_game_studio.sql | 92 ++++++ .../service/studio/StudioServiceImplTest.java | 296 +++++++++++++++++ game-cloud/game-module-studio/pom.xml | 28 ++ game-cloud/pom.xml | 3 + game-cloud/yudao-server/pom.xml | 11 + game-studio/src/api/project.ts | 14 +- game-studio/src/api/types.ts | 41 +++ game-studio/src/host/GamePlayer.vue | 51 ++- game-studio/src/host/contract.ts | 6 + game-studio/src/host/inject.ts | 66 ++++ game-studio/src/mock/feed.ts | 3 + game-studio/src/mock/project.ts | 63 +++- game-studio/src/mock/runtime.ts | 7 + game-studio/src/store/project.ts | 15 +- game-studio/src/views/project/Publish.vue | 102 +++--- .../src/views/project/publish-gates.ts | 21 +- game-studio/src/views/share/Share.vue | 15 +- 95 files changed, 5021 insertions(+), 97 deletions(-) create mode 100644 contracts/api-schemas/compliance.yaml create mode 100644 contracts/api-schemas/studio.yaml create mode 100644 contracts/db-schemas/V8.0.0__create_game_studio.sql create mode 100644 contracts/db-schemas/V9.0.0__create_game_compliance.sql create mode 100644 game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/api/AigcApi.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcGenerateReqDTO.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcGenerateRespDTO.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcTaskRespDTO.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcTemplateDTO.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/enums/ApiConstants.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/api/AigcApiImpl.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/pom.xml create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/api/ComplianceGateApi.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/ComplianceGateItemDTO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/ComplianceGateReqDTO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/GateVerdict.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ApiConstants.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/BanStatusEnum.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/BanTargetTypeEnum.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ComplianceVerdictEnum.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ErrorCodeConstants.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/pom.xml create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/api/ComplianceGateApiImpl.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/ComplianceBanController.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanCancelReqVO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanCreateReqVO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanPageReqVO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/UserBanRespVO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/ComplianceRatingController.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/vo/ContentRatingRespVO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/convert/ComplianceConvert.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/ban/UserBanDO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/gate/GateResultDO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/rating/ContentRatingDO.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/ContentRatingMapper.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/GateResultMapper.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/UserBanMapper.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/ComplianceAtomResult.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/IpComplianceSeam.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/StyleComplianceAtom.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanService.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImpl.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateService.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateServiceImpl.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ContentRatingService.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ContentRatingServiceImpl.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/main/resources/db/migration/V9.0.0__create_game_compliance.sql create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImplTest.java create mode 100644 game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateServiceImplTest.java create mode 100644 game-cloud/game-module-compliance/pom.xml create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvertTest.java create mode 100644 game-cloud/game-module-studio/game-module-studio-api/pom.xml create mode 100644 game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/ApiConstants.java create mode 100644 game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/ErrorCodeConstants.java create mode 100644 game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/StudioSessionStatusEnum.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/pom.xml create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/AppStudioController.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioDraftCreateReqVO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioGenerateReqVO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioSessionRespVO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioTaskChainRespVO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/convert/studio/StudioConvert.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioAssetDO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioSessionDO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioTaskChainDO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioAssetMapper.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioSessionMapper.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioTaskChainMapper.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioService.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImpl.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/resources/db/migration/V8.0.0__create_game_studio.sql create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/test/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImplTest.java create mode 100644 game-cloud/game-module-studio/pom.xml diff --git a/contracts/api-schemas/compliance.yaml b/contracts/api-schemas/compliance.yaml new file mode 100644 index 00000000..480ae6f7 --- /dev/null +++ b/contracts/api-schemas/compliance.yaml @@ -0,0 +1,218 @@ +openapi: 3.0.3 +# 契约 #1 API | 模块:compliance(game-module-compliance,Wave3 合规域「锁风门 Gate」)| owner:Wave3 compliance 子 agent 主笔,全员 review +# 职责(架构 Doc B + D2 范围铁律):只做 Gate——发布前合规扫描(聚合风格/IP 原子→verdict)+ 内容分级 + 封禁台账。 +# 【明确不做】审核状态机:审核状态机权威归 project(game_review_record),compliance 绝不重写(D2)。 +# seam(R1):compliance 只交付 ComplianceGateApi.evaluate 供 project.submitPublish 同进程注入(block→admitted=false 不抛异常); +# 不自改 submitPublish、不自登记 publish-blocked 错误码(那条文案由 project 侧 GateResultVO.code 携带)。 +# 封禁(R3 状态分支):写 game_user_ban 台账=主操作恒执行;下架联动仅当游戏==PUBLISHED 时才调 project 状态机; +# 本波 ProjectApi 暂无 reviewProject seam,下架联动标 TODO 留 Phase C 主 agent 收口(只写台账、不裸调不存在的方法)。 +# 降权(R5):降权出流屏蔽 P1 本波不生效(不建 FeedDownweightApi 孤儿 seam、不调 feed)。 +# IP(D5):ip 不独立建模块,仅在 compliance 内留 IpComplianceSeam 扩展点(默认 pass 桩),Wave4 接 ip 只换实现。 +# 端:/rpc-api(project.publish 同进程 Feign 注入)/ /admin-api(管理端 game-admin,RBAC 封禁)/ /app-api(产品端只读分级);前缀由 yudao 框架按 controller 包名/RpcConstants 自动添加 +# 错误码段:compliance = 1-109-***-***(本模块独占,禁止与他模块重叠:100 project/101 aigc/102 runtime/103 feed/104 telemetry/106 trade/111 ad/112 studio) +# publish-blocked 不在本模块登记(R1:block→admitted=false 不抛异常)。 +# 响应统一 Yudao CommonResult 信封:{ code, data, msg };code=0 成功 +info: + title: 造梦AI compliance 模块 API + version: 1.0.0 + description: >- + 合规锁风门 Gate("做得出"安全闸门)。project.publish 发布前同步调 evaluate → 聚合风格/IP 合规原子产出 verdict + (pass/review/block)+ 落锁风门台账 + 落内容分级;block 则 project 置 admitted=false(不抛异常)。 + 封禁台账供 admin 处置游戏/用户;下架联动按 project 状态分支(本波 TODO 留 Phase C)。MVP 合规原子全 mock 桩(默认 pass)。 + +servers: + - url: http://localhost:48080 + description: 本地(Swagger/Knife4j http://localhost:48080/doc.html) + +paths: + # =========================================================================== + # RPC 端 /rpc-api(project.publish 同进程注入,唯一 seam,R1) + # =========================================================================== + /rpc-api/compliance/evaluate: + post: + tags: [rpc-compliance] + summary: 锁风门评估(project.publish 注入用,R1 唯一形态) + description: >- + project.submitPublish 发布前同步调;聚合风格/IP 合规原子产出 verdict = max(各原子)(block>review>pass), + 落 game_compliance_gate_result 审计台账 + upsert game_content_rating 分级。 + block 仍正常返回 verdict(不抛异常),由 project 侧据此置 admitted=false。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ComplianceGateReqDTO' } + responses: + '200': + description: 聚合裁决 + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultGateVerdict' } + + # =========================================================================== + # 管理端 /admin-api(封禁台账,RBAC,R3 状态分支) + # =========================================================================== + /admin-api/compliance/ban/create: + post: + tags: [admin-compliance] + summary: 创建封禁(写台账=主操作恒执行;游戏下架联动按 project 状态分支) + description: >- + 落 game_user_ban(主操作恒执行,同目标生效中封禁查重 uk_target_active)。 + R3 下架联动:targetType=游戏 且 game.status==PUBLISHED 时才调 project reviewProject(decision=3); + 非 PUBLISHED 只记封禁、不调状态机、不抛异常。【本波】ProjectApi 暂无 reviewProject seam,下架联动 TODO 留 Phase C。 + R5 降权出流屏蔽 P1 本波不生效(不调 feed)。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/BanCreateReqVO' } + responses: + '200': + description: 新建封禁台账 ID + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultLong' } + /admin-api/compliance/ban/cancel: + post: + tags: [admin-compliance] + summary: 解封(status 1→0) + description: 按 banId 解封;记录不存在或已解封报 COMPLIANCE_BAN_NOT_EXISTS。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/BanCancelReqVO' } + responses: + '200': + description: 是否成功 + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultBoolean' } + /admin-api/compliance/ban/page: + get: + tags: [admin-compliance] + summary: 封禁台账分页(admin RBAC) + parameters: + - { name: pageNo, in: query, required: true, schema: { type: integer, default: 1 } } + - { name: pageSize, in: query, required: true, schema: { type: integer, default: 10 } } + - { name: targetType, in: query, required: false, schema: { type: integer }, description: '目标类型过滤:1 游戏/2 用户' } + - { name: status, in: query, required: false, schema: { type: integer }, description: '状态过滤:1 生效/0 已解封' } + responses: + '200': + description: 分页结果 + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultPageUserBan' } + + # =========================================================================== + # 产品端 /app-api(内容分级只读,用户/匿名 Token) + # =========================================================================== + /app-api/compliance/rating/get: + get: + tags: [app-compliance] + summary: 取内容分级(只读) + description: 按游戏+版本读分级(all/8+/12+/16+);无记录报 COMPLIANCE_RATING_NOT_FOUND。分级写入仅由 Gate evaluate + admin 复核产生。 + parameters: + - { name: gameId, in: query, required: true, schema: { type: integer, format: int64 } } + - { name: versionId, in: query, required: false, schema: { type: integer, format: int64 }, description: '无版本传 0' } + responses: + '200': + description: 内容分级 + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultContentRating' } + +components: + schemas: + # ===== 锁风门 Gate(RPC seam,R1 唯一形态)===== + ComplianceGateReqDTO: + type: object + required: [gameId, versionId] + properties: + gameId: { type: integer, format: int64, description: '游戏项目 ID(锁风门定位锚点)' } + versionId: { type: integer, format: int64, description: '待发布版本 ID' } + title: { type: string, description: '标题(风格原子参考,可空)' } + summary: { type: string, description: '摘要(风格原子参考,可空)' } + ageRating: { type: string, description: '适龄分级建议:all/8+/12+/16+' } + promptHash: { type: string, description: '生成 Prompt 摘要(合规溯源,可空)' } + GateVerdict: # = 草案 ComplianceGateRespDTO,R1 命名对齐 + type: object + required: [verdict] + properties: + verdict: { type: string, enum: [pass, review, block], description: '聚合裁决 = max(各原子),block>review>pass' } + rating: { type: string, description: '本次评估落定的内容分级' } + detail: { type: array, items: { $ref: '#/components/schemas/ComplianceGateItemDTO' } } + ComplianceGateItemDTO: + type: object + properties: + atom: { type: string, description: '原子标识:textContent/imageContent/ip-reserved' } + verdict: { type: string, enum: [pass, review, block] } + reason: { type: string, description: '裁决原因(pass 可空)' } + + # ===== 封禁(admin,R3 状态分支)===== + BanCreateReqVO: + type: object + required: [targetType, targetId] + properties: + targetType: { type: integer, description: '封禁目标类型:1 游戏 / 2 用户' } + targetId: { type: integer, format: int64, description: '封禁目标 ID(游戏=game_id / 用户=user_id)' } + banType: { type: integer, description: '处置类型:1 封禁 / 2 降权(P1 本波不生效)' } + reason: { type: string, description: '封禁/降权原因' } + BanCancelReqVO: + type: object + required: [banId] + properties: + banId: { type: integer, format: int64, description: '封禁台账记录 ID' } + UserBanRespVO: + type: object + properties: + id: { type: integer, format: int64 } + targetType: { type: integer, description: '1 游戏 / 2 用户' } + targetId: { type: integer, format: int64 } + banType: { type: integer, description: '1 封禁 / 2 降权' } + status: { type: integer, description: '1 生效 / 0 已解封' } + reason: { type: string } + createTime: { type: string, format: date-time } + + # ===== 分级(app 只读)===== + ContentRatingRespVO: + type: object + properties: + rating: { type: string, description: 'all/8+/12+/16+' } + ratedBy: { type: string, description: 'gate 锁风门自动 / admin 人工复核' } + updateTime: { type: string, format: date-time } + + # ===== CommonResult 信封 ===== + CommonResultGateVerdict: + type: object + properties: + code: { type: integer } + data: { $ref: '#/components/schemas/GateVerdict' } + msg: { type: string } + CommonResultContentRating: + type: object + properties: + code: { type: integer } + data: { $ref: '#/components/schemas/ContentRatingRespVO' } + msg: { type: string } + CommonResultPageUserBan: + type: object + properties: + code: { type: integer } + data: + type: object + properties: + list: { type: array, items: { $ref: '#/components/schemas/UserBanRespVO' } } + total: { type: integer, format: int64 } + msg: { type: string } + CommonResultLong: + type: object + properties: + code: { type: integer } + data: { type: integer, format: int64 } + msg: { type: string } + CommonResultBoolean: + type: object + properties: + code: { type: integer } + data: { type: boolean } + msg: { type: string } diff --git a/contracts/api-schemas/feed.yaml b/contracts/api-schemas/feed.yaml index 5cdf7665..044aaddf 100644 --- a/contracts/api-schemas/feed.yaml +++ b/contracts/api-schemas/feed.yaml @@ -261,6 +261,7 @@ components: ogImage: { type: string, description: 'OG 封面图 URL' } ogDescription: { type: string, description: 'OG 描述' } channel: { type: string, description: 回显的渠道标识(渠道归因 T-FED-10) } + versionId: { type: integer, format: int64, description: '当前生效版本 ID(GAP-3,与 FeedItem.versionId 同源 project.currentVersionId;仅已发布游戏回填,未发布或无生效版本留空,供分享落地页直达即玩加载)' } # ---- 精选池运营 ---- FeaturedReqVO: diff --git a/contracts/api-schemas/runtime.yaml b/contracts/api-schemas/runtime.yaml index 1282bc3c..960b1d2e 100644 --- a/contracts/api-schemas/runtime.yaml +++ b/contracts/api-schemas/runtime.yaml @@ -210,12 +210,17 @@ components: # ---- 运行包清单(脊柱对外产物:宿主据此加载 + 校验,对齐 #4 manifest 段)---- RuntimePackageRespVO: type: object - description: 版本运行包清单。字段语义对齐 GamePackage(#4).manifest,宿主据此渲染 iframe 并桥接 SDK(#3)。 + description: >- + 版本运行包清单。字段语义对齐 GamePackage(#4).manifest,宿主据此渲染 iframe 并桥接 SDK(#3)。 + 【GAP-2 取包约束】① 宿主先 fetch manifestUrl 取 manifest,按 checksum 做 sha256 完整性校验通过后再注入运行容器(T-RT-15),校验失败拒绝加载并落 error; + ② OSS/CDN 须放行宿主 origin 的 CORS(AllowedOrigin=宿主 origin、AllowedMethod=GET/HEAD、ExposeHeader=ETag),否则宿主跨域 fetch 被拦; + ③ scene=preview|play 鉴权与取包门禁须一致:preview 仅版本 owner 可取未发布包、play 仅放行已发布包(与 getPackageManifest 门禁同源,不在前端兜底)。 properties: gameId: { type: integer, format: int64, description: 游戏 ID } versionId: { type: integer, format: int64, description: 版本 ID } templateId: { type: string, description: 玩法模板 ID(宿主据此选 Runtime 容器) } packageUrl: { type: string, description: 'GamePackage(manifest)OSS/CDN URL,按 /games/{gameId}/versions/{versionId}/ 版本化(#4)' } + manifestUrl: { type: string, description: 'GAP-2 分支A(纯派生,零 DDL):由 packageUrl 同前缀派生 .../manifest.json;宿主先 fetch 并按 checksum 做 sha256 完整性校验后再注入' } entry: { type: string, description: 入口文件相对路径(#4 manifest.entry) } runtimeVersion: { type: string, description: '目标 WanxiangGameSDK / Canvas Runtime 版本(semver,#4 manifest.runtimeVersion)' } preloadPolicy: { type: string, enum: [eager, lazy], description: '预加载策略(#4 manifest.preloadPolicy)' } @@ -225,10 +230,13 @@ components: traceId: { type: string, description: '贯穿 生成→编译→加载→运行 的 trace_id(#3 init / #4 provenance)' } SandboxPolicyVO: type: object - description: iframe 沙箱隔离策略(T-RT-04),宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14)。 + description: >- + iframe 沙箱隔离策略(T-RT-04),宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14)。 + 【GAP-2 CORS 约束】allowOrigins 同时是 OSS/CDN 取包(manifestUrl/packageUrl)须放行的跨域来源: + OSS 桶 CORS 规则 AllowedOrigin 应与本白名单一致(含宿主 origin),AllowedMethod=GET/HEAD、ExposeHeader=ETag,否则宿主跨域 fetch manifest 被拦。 properties: sandboxAttr: { type: string, description: "iframe sandbox 属性值(如 'allow-scripts allow-same-origin')" } - allowOrigins: { type: array, items: { type: string }, description: 'postMessage 允许的 origin 白名单(宿主侧 #3 双校验)' } + allowOrigins: { type: array, items: { type: string }, description: 'postMessage 允许的 origin 白名单(宿主侧 #3 双校验);亦为 OSS/CDN 取包 CORS 须放行的宿主 origin' } # ---- 试玩会话开/收(脊柱:play_start/end/时长 → 回灌 telemetry #5)---- SessionStartReqVO: diff --git a/contracts/api-schemas/studio.yaml b/contracts/api-schemas/studio.yaml new file mode 100644 index 00000000..f96db7a6 --- /dev/null +++ b/contracts/api-schemas/studio.yaml @@ -0,0 +1,186 @@ +openapi: 3.0.3 +# 契约 #1 API | 模块:studio(game-module-studio,Wave3 创作主链路最小版)| owner:studio 路 +# 职责(架构 Doc B §71-76):有状态创作工作台后端。编排「建草稿(委托 project createProject)→一句话生成(委托 aigc) +# →进度轮询→重生成→模板应用」。不跑 LLM(委托 aigc)、不持久化项目版本(交 project)、不做锁风裁决(调 compliance)。 +# 与 game-studio 前端仓区分:本契约是后端 studio 模块(错误码段 112),game-studio 是产品端前端仓名,二者不同。 +# 端:/app-api(产品端 game-studio,用户 Token);前缀由 yudao 框架按 controller.app 包名自动添加。 +# 错误码段:studio = 1-112-***-***(R4 治理新登记,本模块独占,禁与他模块重叠: +# 100 project/101 aigc/102 runtime/103 feed/104 telemetry/105 pay/106 trade/107 community/108 ip/109 compliance/110 biz/111 ad/112 studio) +# 响应统一 Yudao CommonResult 信封:{ code, data, msg };code=0 成功。 +# 编排链路(MVP 轮询非 MQ,D4): +# /template/list (透传 aigc 模板注册表) → /draft (调 project ProjectApi.createProject 拿 gameId upsert 会话) +# → /generate (调 aigc AigcApi.submitGenerate 落任务链回填 aigc_task_id) → /task/{id} (透传 aigc 进度轮询) +# → /task/{id}/regenerate (调 aigc AigcApi.retryTask 新建 retry_of 任务链) +# x-feign-contracts(studio 是调用方,依赖以下对方 -api seam,本契约不对外暴露 Feign): +# - project-api ProjectApi.createProject(creatorUserId, title, templateId) → gameId(写类 RPC,显式 creatorUserId,归属校验在 project service) +# - aigc-api AigcApi.submitGenerate / getTask / retryTask / getTemplateList(Wave3 新增 seam,纯暴露层委托 AigcTaskService) +# attachments(R7):StudioGenerateReqVO.attachments 本波仅持久化(studio 侧落库),不透传 aigc(aigc reqDTO 无 attachments 字段);P1 再透传。 +# 狠切留 seam 不实现(P1):rig/对白树/可视化批量;资产图仅槽位表(game_studio_asset)+ 单步调度,不六路并发。 +info: + title: 造梦AI studio 模块 API + version: 1.0.0 + description: >- + 创作主链路最小版("做得出"工作台)。创作者进工作坊建草稿(移交 project 建游戏拿 gameId)→ 一句话生成(委托 aigc)→ + 轮询进度直至终态(透传 aigc)→ 失败可重生成(委托 aigc retryTask)。MVP 轮询非 MQ;前端据此 vite-plugin-mock 自动生成 mock。 + +servers: + - url: http://localhost:48080 + description: 本地(Swagger/Knife4j http://localhost:48080/doc.html) + +paths: + # =========================================================================== + # 产品端 /app-api(创作工作台,用户 Token) + # =========================================================================== + /app-api/studio/template/list: + get: + tags: [app-studio] + summary: 玩法模板列表(模板浏览/应用 P-TPL-01/03,透传 aigc 模板注册表) + description: 透传 aigc 模板注册表(T-AGC-03);MVP 骨架返回空列表占位,待模板注册表接入。 + responses: + '200': + description: 成功(玩法模板列表) + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultStudioTemplateList' } + + /app-api/studio/draft: + post: + tags: [app-studio] + summary: 建草稿(进工作坊 P-CRT-10 + 草稿移交 project P-TPL-03) + description: >- + 编排:服务端调 project ProjectApi.createProject(creatorUserId=当前登录用户, title, templateId) 建游戏拿 gameId + 回写会话,再落会话(status=0 草稿)。attachments 仅持久化(R7)。createProject 是硬前置,返回空 gameId 视为移交失败。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/StudioDraftCreateReqVO' } + responses: + '200': + description: 成功(新建会话,含回写的 gameId) + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultStudioSession' } + + /app-api/studio/generate: + post: + tags: [app-studio] + summary: 一句话生成(P-CRT-01,委托 aigc 提交生成) + description: >- + 编排:校验 session 归属 → 落任务链(status=1 生成中) → 委托 aigc AigcApi.submitGenerate(带 prompt/templateId/gameId/userId) + → 回填 aigc_task_id → 立即返回任务链句柄,前端据 taskChainId 轮询。同步直写无 MQ(D4)。 + attachments 仅持久化(R7),不入 aigc reqDTO。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/StudioGenerateReqVO' } + responses: + '200': + description: 成功(任务链句柄,含 aigc_task_id) + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultStudioTaskChain' } + + /app-api/studio/task/{id}: + get: + tags: [app-studio] + summary: 进度轮询(P-CRT-12,透传 aigc 进度/状态) + description: >- + 编排:校验任务链归属 → 委托 aigc AigcApi.getTask 取进度/状态 → 映射链态 + (aigc succeeded → 链完成回填 version_id + 会话完成;failed/timed_out → 链失败 + 会话失败;其余保持生成中仅回写 progress)。 + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: '创作任务链 ID(taskChainId)' } + responses: + '200': + description: 成功(任务链最新态,完成后 versionId 非空) + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultStudioTaskChain' } + + /app-api/studio/task/{id}/regenerate: + post: + tags: [app-studio] + summary: 重生成迭代(P-CRT-09,委托 aigc retryTask) + description: 编排:校验原任务链归属 → 委托 aigc AigcApi.retryTask 重提生成 → 新建一条任务链(retry_of=原 id)回填新 aigc_task_id。 + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: '原创作任务链 ID' } + responses: + '200': + description: 成功(新建的重试任务链句柄) + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultStudioTaskChain' } + +components: + schemas: + # ========== 请求 VO ========== + StudioDraftCreateReqVO: + type: object + required: [title, templateId] + properties: + title: { type: string, maxLength: 128, description: '作品标题(透传 project createProject)', example: '我的放置小镇' } + templateId: { type: string, description: '玩法模板 ID(透传 project/aigc)', example: 'idle' } + prompt: { type: string, maxLength: 1000, description: '一句话 Prompt(建草稿可空,generate 必填)', example: '做一个躲避陨石的小游戏' } + attachments: { type: string, description: '附件上下文 JSON(P-CRT-04);本波仅持久化,P1 透传 aigc' } + + StudioGenerateReqVO: + type: object + required: [sessionId, prompt] + properties: + sessionId: { type: integer, format: int64, description: '创作会话 ID(建草稿返回的 sessionId)', example: 1024 } + prompt: { type: string, maxLength: 1000, description: '一句话 Prompt(透传 aigc submitGenerate)', example: '做一个躲避陨石的小游戏' } + attachments: { type: string, description: '附件上下文 JSON(P-CRT-04);【本波仅持久化,P1 透传 aigc】——studio 侧落库持久化,本波不透传 aigc' } + + # ========== 响应 VO ========== + StudioTemplateRespVO: + type: object + description: 玩法模板(透传 aigc 模板注册表,对齐 aigc.yaml TemplateRespVO) + properties: + templateId: { type: string, description: '模板 ID', example: 'dodge' } + name: { type: string, description: '模板名称', example: '躲避' } + description: { type: string, description: '模板说明' } + coverUrl: { type: string, description: '模板封面/示意图 URL' } + examplePrompt: { type: string, description: '示例 Prompt(P-CRT-11)', example: '做一个躲避陨石的小游戏' } + + StudioSessionRespVO: + type: object + properties: + id: { type: integer, format: int64, description: '创作会话 ID(sessionId)', example: 1024 } + gameId: { type: integer, format: int64, description: '移交后的游戏 ID(project.game_project.id)', example: 2048 } + templateId: { type: string, description: '玩法模板 ID', example: 'idle' } + title: { type: string, description: '作品标题', example: '我的放置小镇' } + prompt: { type: string, description: '一句话 Prompt' } + status: { type: integer, description: '会话状态:0草稿 1生成中 2完成 3失败', example: 0 } + + StudioTaskChainRespVO: + type: object + properties: + id: { type: integer, format: int64, description: '创作任务链 ID(taskChainId,轮询用)', example: 1024 } + sessionId: { type: integer, format: int64, description: '所属创作会话 ID', example: 512 } + aigcTaskId: { type: integer, format: int64, description: '委托的 aigc 生成任务 ID', example: 8888 } + gameId: { type: integer, format: int64, description: '关联游戏 ID(project.game_project.id)', example: 2048 } + versionId: { type: integer, format: int64, description: '产物版本 ID(project.game_version.id);完成后非空', example: 4096 } + status: { type: integer, description: '任务链状态:0初始 1生成中 2完成 3失败', example: 1 } + progress: { type: integer, description: '进度百分比 0-100(透传 aigc)', example: 60 } + retryOf: { type: integer, format: int64, description: '若为重生成,指向原任务链 ID(P-CRT-09 血缘)', example: 1000 } + + # ========== CommonResult 信封 ========== + CommonResultStudioTemplateList: + type: object + properties: + code: { type: integer, example: 0 } + data: { type: array, items: { $ref: '#/components/schemas/StudioTemplateRespVO' } } + msg: { type: string } + CommonResultStudioSession: + type: object + properties: + code: { type: integer, example: 0 } + data: { $ref: '#/components/schemas/StudioSessionRespVO' } + msg: { type: string } + CommonResultStudioTaskChain: + type: object + properties: + code: { type: integer, example: 0 } + data: { $ref: '#/components/schemas/StudioTaskChainRespVO' } + msg: { type: string } diff --git a/contracts/db-schemas/V8.0.0__create_game_studio.sql b/contracts/db-schemas/V8.0.0__create_game_studio.sql new file mode 100644 index 00000000..9e2f2e0b --- /dev/null +++ b/contracts/db-schemas/V8.0.0__create_game_studio.sql @@ -0,0 +1,92 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:studio(game-module-studio,Wave3 创作主链路最小版)| owner:studio 路 +-- 文件:V8.0.0__create_game_studio.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V8.0.1 DROP TABLE) +-- 内容:studio 模块核心表 —— 创作会话 game_studio_session + 创作任务链 game_studio_taskchain + 资产槽位 game_studio_asset +-- 职责(架构 Doc B §71-76):有状态创作工作台后端。编排「建草稿(委托 project createProject)→一句话生成(委托 aigc) +-- →进度轮询→重生成→模板应用」。不跑 LLM(委托 aigc)、不持久化项目版本(交 project)、不做锁风裁决(调 compliance)。 +-- 边界纪律: +-- - 项目/版本权威归 project(game_project / game_version);studio 仅持 game_id/version_id 引用,不在本模块建版本表。 +-- - 生成任务权威归 aigc(game_aigc_task);studio 任务链仅持 aigc_task_id 引用 + 自身链态,不在本模块重建生成状态机。 +-- - attachments 本波仅持久化(R7),不透传 aigc(aigc reqDTO 无 attachments 字段)。 +-- - MVP 进度推送走轮询(D4),非 MQ/SSE。 +-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id; +-- 状态机用 tinyint,非法流转由 Service 校验,DO 层不承载;中文列注释;关键查询建索引、禁裸 select*。 +-- 错误码段:studio = 1-112-***-*** +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 表:game_studio_session —— 创作会话表(有状态创作工作台主记录,对齐 P-CRT-10 进工作坊) +-- 用途:创作者进工作坊 upsert 一条会话,承载草稿入参(title/prompt/templateId/attachments)+ 移交后的 game_id。 +-- 移交:createDraft 调 project ProjectApi.createProject 拿 gameId 回写本表 game_id(草稿移交 project,P-TPL-03)。 +-- attachments:JSON 持久化(本波仅持久化不透传 aigc,R7)。 +-- 状态机 status:0 草稿 → 1 生成中 → 2 完成 / 3 失败(StudioSessionStatusEnum;由 generate/getTask 透传 aigc 终态映射)。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_studio_session` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '创作会话 ID', + `creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(业务字段;DataPermission:创作者只见自己会话。区别于审计列 creator)', + `game_id` BIGINT NULL DEFAULT NULL COMMENT '移交后的游戏 ID(project.game_project.id);createDraft 调 createProject 后回写', + `template_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '玩法模板 ID(透传 aigc,如 dodge/runner/clicker/puzzle/shooter)', + `title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '作品标题(建草稿入参,透传 project createProject)', + `prompt` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '一句话自然语言 Prompt(透传 aigc submitGenerate)', + `attachments` JSON NULL DEFAULT NULL COMMENT '附件上下文(P-CRT-04,JSON 数组);本波仅持久化,P1 透传 aigc', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '会话状态机:0草稿 1生成中 2完成 3失败(StudioSessionStatusEnum;Service 校验流转)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `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`), + KEY `idx_creator` (`creator_user_id`, `id`) COMMENT '创作者「我的创作会话」列表(DataPermission 边界 + 按 id 倒序)', + KEY `idx_game` (`game_id`) COMMENT '按移交后的游戏 ID 反查会话' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作会话表(有状态创作工作台主记录;草稿入参 + 移交 game_id)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_studio_taskchain —— 创作任务链表(线性单步编排,对齐 P-CRT-01/09 一句话生成 + 重生成血缘) +-- 用途:generate 时落一条任务链(status=1),回填 aigc_task_id;getTask 轮询透传 aigc 进度/状态映射链态。 +-- 引用边界:aigc_task_id 引用 aigc.game_aigc_task.id(生成权威归 aigc);version_id 引用 project.game_version.id +-- (aigc succeeded 后回填,版本权威归 project);本表只持引用 + 自身链态,不重建生成状态机。 +-- 重生成血缘:retry_of 指向原任务链 id(P-CRT-09);regenerate 新建一条 retry_of=原 id 的链。 +-- 链态 status:0 初始 → 1 生成中 → 2 完成(回填 version_id) / 3 失败(透传 aigc failed/timed_out)。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_studio_taskchain` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '创作任务链 ID', + `session_id` BIGINT NOT NULL COMMENT '所属创作会话 ID(= game_studio_session.id)', + `creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(DataPermission 边界,归属校验用)', + `aigc_task_id` BIGINT NULL DEFAULT NULL COMMENT '委托的 aigc 生成任务 ID(= aigc.game_aigc_task.id);generate 后回填,轮询据此查 aigc', + `game_id` BIGINT NULL DEFAULT NULL COMMENT '关联游戏 ID(project.game_project.id,透传 aigc 用)', + `version_id` BIGINT NULL DEFAULT NULL COMMENT '产物版本 ID(project.game_version.id);aigc succeeded 后由 getTask 回填', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '任务链状态机:0初始 1生成中 2完成 3失败(Service 校验流转,透传 aigc 终态映射)', + `progress` INT NOT NULL DEFAULT 0 COMMENT '进度百分比 0-100(透传 aigc progress,供前端进度条 P-CRT-12)', + `retry_of` BIGINT NULL DEFAULT NULL COMMENT '若为重生成,指向原任务链 ID(P-CRT-09 重生成血缘)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `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`), + KEY `idx_session` (`session_id`, `id`) COMMENT '按会话查任务链(含重生成历史,按 id 倒序)', + KEY `idx_aigc` (`aigc_task_id`) COMMENT '按 aigc 任务 ID 反查任务链(轮询回写映射)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作任务链表(线性单步编排;持 aigc_task_id/version_id 引用 + 链态)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_studio_asset —— 创作资产槽位表(单步出整包,对齐 P-CRT-02;MVP 仅落槽位 seam,不六路并发) +-- 用途:单步生成产物的资产槽位记录(asset_type 槽位 + asset_url + 槽位状态)。 +-- 狠切(R7/草稿 scope):MVP 只建槽位表占位 seam,不实现六路并发资产生成;资产由 aigc 单步整包产出,studio 仅登记槽位。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_studio_asset` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '资产槽位 ID', + `session_id` BIGINT NOT NULL COMMENT '所属创作会话 ID(= game_studio_session.id)', + `asset_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '资产槽位类型(如 sprite/background/audio/icon;MVP 槽位占位)', + `asset_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '资产 URL(OSS/MinIO;单步整包产出后登记)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '槽位状态:0未生成 1已生成(Service 校验,MVP 单步直填)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `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`), + KEY `idx_session_type` (`session_id`, `asset_type`) COMMENT '按会话+槽位类型查资产' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作资产槽位表(单步出整包槽位 seam;MVP 不六路并发)'; diff --git a/contracts/db-schemas/V9.0.0__create_game_compliance.sql b/contracts/db-schemas/V9.0.0__create_game_compliance.sql new file mode 100644 index 00000000..696a49dd --- /dev/null +++ b/contracts/db-schemas/V9.0.0__create_game_compliance.sql @@ -0,0 +1,84 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:compliance(game-module-compliance,Wave3 合规域「锁风门 Gate」)| owner:Wave3 compliance 子 agent +-- 文件:V9.0.0__create_game_compliance.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V9.0.1 DROP TABLE) +-- 内容:compliance 模块三张表 —— 锁风门评估台账 game_compliance_gate_result + 内容分级 game_content_rating + 封禁台账 game_user_ban +-- 职责(架构 Doc B + D2 范围铁律):只做 Gate(聚合风格/IP 原子→verdict + 内容分级 + 封禁台账); +-- 【明确不建】审核状态机表 / audit_record —— 审核状态机权威归 project(game_review_record),compliance 绝不重写。 +-- 锁风门 seam(R1):project.submitPublish 同进程调 ComplianceGateApi.evaluate → 聚合裁决落 game_compliance_gate_result + 分级落 game_content_rating +-- → 返回 verdict 供 project 据此置 admitted(block→admitted=false 不抛异常)。 +-- 封禁联动(R3 状态分支):写 game_user_ban 台账(主操作恒执行);下架仅当 project 状态==PUBLISHED 时才调 project reviewProject(3); +-- 非 PUBLISHED 只记封禁不调状态机。【本波说明】ProjectApi 暂无 reviewProject seam,下架联动标 TODO 留 Phase C 主 agent 收口,本波仅写台账。 +-- 降权出流屏蔽:P1 本波不生效(R5,不建 FeedDownweightApi 孤儿 seam)。 +-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id; +-- 状态机用 tinyint,非法流转由 Service 校验,DO 层不承载;中文列注释;关键查询建索引、禁裸 select*。 +-- 错误码段:compliance = 1-109-***-*** +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 表:game_compliance_gate_result —— 锁风门评估台账(每次 publish 评估各落一条,审计/溯源用) +-- 用途:project.publish 注入 evaluate 时落聚合裁决 + 各原子明细 JSON;admin 复核与合规溯源据此查 +-- 写口径:非幂等(每次评估各落一条审计记录,不去重);verdict 为聚合结果 max(各原子) +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_compliance_gate_result` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '锁风门评估记录 ID', + `game_id` BIGINT NOT NULL COMMENT '评估的游戏 ID(project.game_project.id)', + `version_id` BIGINT NOT NULL DEFAULT 0 COMMENT '评估的版本 ID(待发布版本;无版本时为 0)', + `verdict` VARCHAR(16) NOT NULL DEFAULT 'pass' COMMENT '聚合裁决:pass 通过/review 需人工复核/block 拦截(= max(各原子))', + `rating` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '本次评估落定的内容分级:all/8+/12+/16+(可空)', + `details` VARCHAR(2048) NOT NULL DEFAULT '' COMMENT '各合规原子明细 JSON([{atom,verdict,reason}],便于复核溯源)', + `trace_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '全链路追踪 ID(与 publish 链路对账)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;锁风门评估为 system/发布发起方)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(评估时间)', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `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`), + KEY `idx_game_version` (`game_id`, `version_id`) COMMENT '按游戏+版本查最近一次锁风门评估(复核/溯源)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '锁风门评估台账(每次 publish 评估落一条,聚合裁决+原子明细;审计/溯源)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_content_rating —— 内容分级表(按游戏+版本唯一;评估时落、admin 复核可改) +-- 用途:/app-api/compliance/rating/get 读分级;分级写入仅由 Gate evaluate 落定 + admin 复核产生 +-- 唯一键:(game_id,version_id) 业务唯一(含 deleted/tenant_id 适配逻辑删+多租户),同游戏同版本只存一条最新分级 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_content_rating` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '内容分级记录 ID', + `game_id` BIGINT NOT NULL COMMENT '游戏 ID(project.game_project.id)', + `version_id` BIGINT NOT NULL DEFAULT 0 COMMENT '版本 ID(无版本时为 0)', + `rating` VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '内容分级:all 全年龄/8+/12+/16+', + `rated_by` VARCHAR(32) NOT NULL DEFAULT 'gate' COMMENT '分级来源:gate 锁风门自动/admin 人工复核', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(admin 复核改分级时记录)', + `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_game_version` (`game_id`, `version_id`, `deleted`, `tenant_id`) COMMENT '游戏+版本分级业务唯一键(含 deleted/tenant_id 适配逻辑删+多租户)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '内容分级表(评估落定+admin 复核;/app-api 只读取)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_user_ban —— 封禁台账(封禁/降权的权威台账;主操作恒执行) +-- 用途:admin 封禁游戏/用户落台账(恒执行);R3 下架联动仅当游戏==PUBLISHED 时才调 project 状态机(本波标 TODO); +-- R5 降权出流屏蔽 P1 本波不生效(不建 feed seam) +-- 唯一键:(target_type,target_id,status) 生效中唯一(status=1 生效,含 deleted/tenant_id),同目标不可重复生效封禁;解封后(status=0)可再次封禁 +-- 状态机 status:1 生效 → 0 已解封(解封由 admin 触发,Service 校验合法流转) +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_user_ban` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '封禁台账记录 ID', + `target_type` TINYINT NOT NULL COMMENT '封禁目标类型:1 游戏 / 2 用户', + `target_id` BIGINT NOT NULL COMMENT '封禁目标 ID(游戏=game_id / 用户=user_id)', + `ban_type` TINYINT NOT NULL DEFAULT 1 COMMENT '处置类型:1 封禁(下架/禁用)/ 2 降权(出流屏蔽,P1 本波不生效)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '封禁状态机:1 生效 / 0 已解封(合法流转由 Service 校验)', + `reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封禁/降权原因(合规处置说明)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;封禁操作的 admin userId)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(封禁时间)', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(解封操作的 admin userId)', + `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_target_active` (`target_type`, `target_id`, `status`, `deleted`, `tenant_id`) COMMENT '生效中封禁业务唯一键:同目标 status=1 不可重复(含 deleted/tenant_id 适配逻辑删+多租户)', + KEY `idx_type_status` (`target_type`, `status`) COMMENT 'admin 按目标类型+状态分页查封禁台账' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '封禁台账表(封禁/降权权威台账;主操作恒执行,下架联动按 project 状态分支)'; diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/api/AigcApi.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/api/AigcApi.java new file mode 100644 index 00000000..86ad222c --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/api/AigcApi.java @@ -0,0 +1,96 @@ +package cn.wanxiang.game.module.aigc.api; + +import cn.wanxiang.game.module.aigc.dto.AigcGenerateReqDTO; +import cn.wanxiang.game.module.aigc.dto.AigcGenerateRespDTO; +import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO; +import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO; +import cn.wanxiang.game.module.aigc.enums.ApiConstants; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +/** + * RPC 服务 - AI 生成 API(aigc → studio seam,Wave3 新增) + * + * 用途:studio 创作主链路(一句话生成/进度轮询/重生成/模板浏览)委托 aigc 既有 {@code AigcTaskService}。 + * 设计纪律:本 seam 为纯暴露层(@RestController @Primary 委托 service,不改 service),删除后 aigc 既有单测仍绿。 + * 跨模块只依赖 aigc 的 -api(cn.wanxiang.game.module.aigc.api/.dto),禁止 studio 依赖 aigc 的 -server,避免编译耦合。 + * + * MVP 单体:同进程内由 {@code AigcApiImpl}(@RestController @Primary) 就地解析(仿 yudao DictDataApi); + * 拆微服务后按 {@link ApiConstants#NAME} 走真实 Feign。 + * + * 写类 RPC(submitGenerate/getTask/retryTask)无登录上下文:userId 由调用方(studio 受信边界)显式传入, + * 不从匿名上下文推断、不裸暴露给前端;service 层据此做数据归属校验(创作者只见自己任务)。 + * + * @author 造梦AI + */ +@FeignClient(name = ApiConstants.NAME) +@Tag(name = "RPC 服务 - AI 生成") +public interface AigcApi { + + String PREFIX = ApiConstants.PREFIX; + + /** + * 玩法模板列表(studio 模板浏览/应用 P-TPL-01/03 透传 aigc 模板注册表) + * + * 只读、幂等;委托 {@code AigcTaskService.getTemplateList}(MVP 骨架返回空列表占位,待模板注册表接入)。 + * + * @return 启用的模板列表(CommonResult 包裹) + */ + @GetMapping(PREFIX + "/template/list") + @Operation(summary = "玩法模板列表(studio 透传)") + CommonResult> getTemplateList(); + + /** + * 提交生成任务(studio 一句话生成 P-CRT-01 委托;异步入队,立即返回 taskId,初始 status=0 queued) + * + * 委托 {@code AigcTaskService.submitGenerate(reqVO, userId)};userId 取 req.userId(写类 RPC 显式入参)。 + * + * @param req 生成入参(prompt/templateId/gameId/versionId + 显式 userId) + * @return 任务句柄(taskId/status/traceId;CommonResult 包裹) + */ + @PostMapping(PREFIX + "/submit-generate") + @Operation(summary = "提交生成任务(studio 委托,异步入队)") + CommonResult submitGenerate(@RequestBody AigcGenerateReqDTO req); + + /** + * 轮询生成任务状态(studio 进度轮询 P-CRT-12 透传;校验归属:创作者只见自己任务) + * + * 委托 {@code AigcTaskService.getTask(taskId, userId)};非归属任务由 aigc service 抛归属错误码。 + * + * @param taskId 生成任务 ID + * @param userId 当前登录创作者 ID(显式入参,归属校验用) + * @return 任务状态(status/progress/versionId/failureReason;CommonResult 包裹) + */ + @GetMapping(PREFIX + "/get-task") + @Operation(summary = "轮询生成任务状态(studio 透传)") + @Parameter(name = "taskId", description = "生成任务 ID", required = true, example = "1024") + @Parameter(name = "userId", description = "当前登录创作者 ID", required = true, example = "99") + CommonResult getTask(@RequestParam("taskId") Long taskId, + @RequestParam("userId") Long userId); + + /** + * 重试生成任务(studio 重生成迭代 P-CRT-09 委托;仅终态失败可重试,基于原任务新建一条入队) + * + * 委托 {@code AigcTaskService.retryTask(taskId, userId)};retry_of 指向原任务记录重生成血缘。 + * + * @param taskId 原生成任务 ID + * @param userId 当前登录创作者 ID(显式入参,归属校验用) + * @return 新建的重试任务句柄(taskId/status/traceId;CommonResult 包裹) + */ + @PostMapping(PREFIX + "/retry-task") + @Operation(summary = "重试生成任务(studio 委托重生成)") + @Parameter(name = "taskId", description = "原生成任务 ID", required = true, example = "1024") + @Parameter(name = "userId", description = "当前登录创作者 ID", required = true, example = "99") + CommonResult retryTask(@RequestParam("taskId") Long taskId, + @RequestParam("userId") Long userId); + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcGenerateReqDTO.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcGenerateReqDTO.java new file mode 100644 index 00000000..753f49a2 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcGenerateReqDTO.java @@ -0,0 +1,39 @@ +package cn.wanxiang.game.module.aigc.dto; + +import lombok.Data; + +/** + * 提交生成任务跨模块入参(studio → aigc,对应 aigc-server AigcGenerateReqVO 的 RPC 镜像) + * + * 用途:studio 创作编排「一句话生成」(P-CRT-01) 委托 aigc 提交生成任务。 + * 写类 RPC 无登录上下文:userId 由调用方(studio service 受信边界)显式传入,不从匿名上下文推断、不裸暴露给前端。 + * 注意(R7 attachments 持久化-only):本 DTO 不含 attachments 字段——studio 侧 attachments 仅落库持久化、本波不透传 aigc; + * P1 若需透传,再 additive 给本 DTO 加字段,不在本波私造。 + * + * @author 造梦AI + */ +@Data +public class AigcGenerateReqDTO { + + /** + * 当前登录创作者 ID(写类 RPC 显式入参,由 studio 受信边界传入) + */ + private Long userId; + /** + * 创作者一句话自然语言 Prompt(→ aigc 契约#6 input.prompt) + */ + private String prompt; + /** + * 玩法模板 ID(→ aigc 契约#6 input.templateId;空则由 Dify 意图解析匹配) + */ + private String templateId; + /** + * 关联游戏 ID(project.game_project.id);studio 草稿移交后回填,产物经此落 game_version + */ + private Long gameId; + /** + * 关联版本 ID(project.game_version.id);指定则产物写入该版本,为空时由生成流程新建版本 + */ + private Long versionId; + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcGenerateRespDTO.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcGenerateRespDTO.java new file mode 100644 index 00000000..0ba188a5 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcGenerateRespDTO.java @@ -0,0 +1,28 @@ +package cn.wanxiang.game.module.aigc.dto; + +import lombok.Data; + +/** + * 提交/重试生成任务跨模块返回(aigc → studio,对应 aigc-server GenerateRespVO 的 RPC 镜像) + * + * 异步语义:提交/重试立即返回 taskId(status=0 queued),studio 据 taskId 轮询直至终态(getTask)。 + * + * @author 造梦AI + */ +@Data +public class AigcGenerateRespDTO { + + /** + * 生成任务 ID(= 对外 taskId,studio 回填 taskchain.aigc_task_id 后轮询用) + */ + private Long taskId; + /** + * 初始状态:0 queued(对齐 AigcTaskStatusEnum) + */ + private Integer status; + /** + * 贯穿全链路 traceId(透传给 studio/日志关联) + */ + private String traceId; + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcTaskRespDTO.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcTaskRespDTO.java new file mode 100644 index 00000000..0dc6749f --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcTaskRespDTO.java @@ -0,0 +1,42 @@ +package cn.wanxiang.game.module.aigc.dto; + +import lombok.Data; + +/** + * 生成任务状态跨模块返回(aigc → studio,对应 aigc-server AigcTaskRespVO/AigcTaskDO 的 RPC 镜像) + * + * 用途:studio 进度轮询 (P-CRT-12) 透传 aigc 任务进度/状态/产物版本,映射为 studio 任务链态: + * aigc succeeded(2) → 链完成回填 version_id;failed(3)/timed_out(4) → 链失败。 + * 字段为 studio 编排所需的最小集(status/progress/versionId/failureReason/traceId),不全量镜像 AigcTaskDO。 + * + * @author 造梦AI + */ +@Data +public class AigcTaskRespDTO { + + /** + * 生成任务 ID(= taskId) + */ + private Long taskId; + /** + * 状态机:0 queued 1 running 2 succeeded 3 failed 4 timed_out 5 canceled(对齐 AigcTaskStatusEnum) + */ + private Integer status; + /** + * 进度百分比 0-100(studio 透传给前端进度条) + */ + private Integer progress; + /** + * 产物落地的版本 ID(project.game_version.id);status=2 succeeded 后非空,studio 据此回填任务链 version_id + */ + private Long versionId; + /** + * 失败原因分类(status=3/4 时非空) + */ + private String failureReason; + /** + * 贯穿全链路 traceId + */ + private String traceId; + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcTemplateDTO.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcTemplateDTO.java new file mode 100644 index 00000000..2121b3e0 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/dto/AigcTemplateDTO.java @@ -0,0 +1,38 @@ +package cn.wanxiang.game.module.aigc.dto; + +import lombok.Data; + +/** + * 玩法模板跨模块传输对象(aigc → studio,对应 aigc.yaml TemplateRespVO 的 RPC 镜像) + * + * 用途:studio 创作工作台「模板浏览/应用」(P-TPL-01/03) 透传 aigc 模板注册表(T-AGC-03)。 + * 字段对齐 aigc-server 的 {@code TemplateRespVO}(不多不少),由 {@code AigcApiImpl} 转换后跨模块返回。 + * 跨模块只依赖 aigc 的 -api(cn.wanxiang.game.module.aigc.dto),禁止 studio 依赖 aigc 的 -server。 + * + * @author 造梦AI + */ +@Data +public class AigcTemplateDTO { + + /** + * 模板 ID(如 dodge/runner/clicker/puzzle/shooter) + */ + private String templateId; + /** + * 模板名称 + */ + private String name; + /** + * 模板说明 + */ + private String description; + /** + * 模板封面/示意图 URL + */ + private String coverUrl; + /** + * 示例 Prompt(P-CRT-11 示例 Prompt 库) + */ + private String examplePrompt; + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/enums/ApiConstants.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/enums/ApiConstants.java new file mode 100644 index 00000000..a2cd32d1 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/cn/wanxiang/game/module/aigc/enums/ApiConstants.java @@ -0,0 +1,34 @@ +package cn.wanxiang.game.module.aigc.enums; + +import cn.iocoder.yudao.framework.common.enums.RpcConstants; + +/** + * aigc 模块 API 相关枚举(Feign 服务名 + RPC 前缀) + * + * 仿 yudao system / project / ad 的 {@code ApiConstants} 风格:aigc 此前仅含枚举无 Feign 契约, + * Wave3 因 studio 创作主链路需委托 aigc 提交生成,故本类与 {@code AigcApi} seam 一并新增(additive,只暴露不改 service)。 + * - NAME:Feign 目标服务名。MVP 为单体(spring.application.name=yudao-server,所有模块编入一个 JAR), + * 同进程内调用由 {@code AigcApiImpl}(@RestController @Primary) 就地解析(与 yudao DictDataApi 同构); + * 拆微服务后该值即对应 aigc 服务的 spring.application.name。 + * - PREFIX:RPC 接口前缀 /rpc-api/aigc,与业务端 /app-api、/admin-api 隔离,避免与业务路由冲突。 + * + * @author 造梦AI + */ +public class ApiConstants { + + /** + * 服务名(拆微服务后需与 aigc 服务的 spring.application.name 一致;MVP 单体下仅作 Feign 客户端名占位) + */ + public static final String NAME = "aigc-server"; + + /** + * RPC API 前缀(/rpc-api/aigc) + */ + public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/aigc"; + + /** + * 接口版本 + */ + public static final String VERSION = "1.0.0"; + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/api/AigcApiImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/api/AigcApiImpl.java new file mode 100644 index 00000000..ebd69dd1 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/api/AigcApiImpl.java @@ -0,0 +1,102 @@ +package cn.wanxiang.game.module.aigc.api; + +import cn.wanxiang.game.module.aigc.controller.app.task.vo.AigcGenerateReqVO; +import cn.wanxiang.game.module.aigc.controller.app.task.vo.TemplateRespVO; +import cn.wanxiang.game.module.aigc.dal.dataobject.task.AigcTaskDO; +import cn.wanxiang.game.module.aigc.dto.AigcGenerateReqDTO; +import cn.wanxiang.game.module.aigc.dto.AigcGenerateRespDTO; +import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO; +import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO; +import cn.wanxiang.game.module.aigc.service.task.AigcTaskService; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Primary; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * AI 生成 API 实现(提供 RESTful 接口给 studio Feign 调用,对应 Wave3 aigc → studio seam) + * + * @RestController + @Primary:与 yudao DictDataApiImpl 同构——单体内同进程调用走本实现,拆微服务后走 Feign。 + * 纯暴露层:仅做 DTO↔VO/DO 转换并委托既有 {@link AigcTaskService},不写任何业务逻辑(业务在 service,不改)。 + * 删除本类后 aigc 既有单测仍绿(service 零改动),即「只暴露不改」回滚门。 + * + * @author 造梦AI + */ +@RestController // 提供 RESTful API 接口,给 Feign 调用 +@Validated +@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析) +public class AigcApiImpl implements AigcApi { + + @Resource + private AigcTaskService aigcTaskService; + + @Override + public CommonResult> getTemplateList() { + // 透传 aigc 模板注册表(MVP 骨架返回空列表占位) + List list = aigcTaskService.getTemplateList(); + List dtoList = list.stream().map(vo -> { + AigcTemplateDTO dto = new AigcTemplateDTO(); + dto.setTemplateId(vo.getTemplateId()); + dto.setName(vo.getName()); + dto.setDescription(vo.getDescription()); + dto.setCoverUrl(vo.getCoverUrl()); + dto.setExamplePrompt(vo.getExamplePrompt()); + return dto; + }).collect(Collectors.toList()); + return success(dtoList); + } + + @Override + public CommonResult submitGenerate(AigcGenerateReqDTO req) { + // DTO → 既有 AigcGenerateReqVO(attachments 不在 req,本波不透传 aigc,R7);userId 取显式入参 + AigcGenerateReqVO reqVO = new AigcGenerateReqVO(); + reqVO.setPrompt(req.getPrompt()); + reqVO.setTemplateId(req.getTemplateId()); + reqVO.setGameId(req.getGameId()); + reqVO.setVersionId(req.getVersionId()); + AigcTaskDO task = aigcTaskService.submitGenerate(reqVO, req.getUserId()); + return success(toGenerateRespDTO(task)); + } + + @Override + public CommonResult getTask(Long taskId, Long userId) { + // 委托既有 getTask(含归属校验,创作者只见自己任务) + AigcTaskDO task = aigcTaskService.getTask(taskId, userId); + AigcTaskRespDTO dto = new AigcTaskRespDTO(); + dto.setTaskId(task.getId()); + dto.setStatus(task.getStatus()); + dto.setProgress(task.getProgress()); + dto.setVersionId(task.getVersionId()); + dto.setFailureReason(task.getFailureReason()); + dto.setTraceId(task.getTraceId()); + return success(dto); + } + + @Override + public CommonResult retryTask(Long taskId, Long userId) { + // 委托既有 retryTask(含状态机/归属校验,基于原任务新建一条 retry_of 任务) + AigcTaskDO retry = aigcTaskService.retryTask(taskId, userId); + return success(toGenerateRespDTO(retry)); + } + + /** + * 任务 DO → 提交/重试返回 DTO(taskId/status/traceId) + * + * @param task 新建/重试任务 DO + * @return 任务句柄 DTO + */ + private AigcGenerateRespDTO toGenerateRespDTO(AigcTaskDO task) { + AigcGenerateRespDTO dto = new AigcGenerateRespDTO(); + dto.setTaskId(task.getId()); + dto.setStatus(task.getStatus()); + dto.setTraceId(task.getTraceId()); + return dto; + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/pom.xml b/game-cloud/game-module-compliance/game-module-compliance-api/pom.xml new file mode 100644 index 00000000..a6ca7af5 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/pom.xml @@ -0,0 +1,48 @@ + + + + cn.iocoder.cloud + game-module-compliance + ${revision} + + 4.0.0 + game-module-compliance-api + jar + + ${project.artifactId} + + compliance 模块 API:错误码常量(段 109)、跨模块 DTO/Feign(ComplianceGateApi 暴露给 project.publish 注入锁风门 Gate),契约先行先定本层。 + + + + + + cn.iocoder.cloud + yudao-common + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + provided + + + + + org.springframework.boot + spring-boot-starter-validation + true + + + + + org.springframework.cloud + spring-cloud-starter-openfeign + true + + + + diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/api/ComplianceGateApi.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/api/ComplianceGateApi.java new file mode 100644 index 00000000..1bbbe51c --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/api/ComplianceGateApi.java @@ -0,0 +1,45 @@ +package cn.wanxiang.game.module.compliance.api; + +import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO; +import cn.wanxiang.game.module.compliance.dto.GateVerdict; +import cn.wanxiang.game.module.compliance.enums.ApiConstants; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * RPC 服务 - 合规锁风门 Gate API(compliance → project.publish seam) + * + * 对应契约 compliance.yaml:compliance 通过本 -api 暴露给 project.publish 注入的唯一 seam(R1)。 + * 用途:project.submitPublish 在发布前同步调 evaluate,据返回 verdict 映射为 key=compliance 的 GateResultVO + * (block → admitted=false 不抛异常;pass/review → 进 REVIEWING)。注入逻辑由主 agent 在 GAP-1 串行写,compliance 只交付本 seam。 + * 跨模块只依赖 compliance 的 -api(cn.wanxiang.game.module.compliance.api),禁止 project 依赖 compliance 的 -server。 + * + * MVP 单体:同进程内由 {@code ComplianceGateApiImpl}(@RestController @Primary) 就地解析(仿 yudao DictDataApi); + * 拆微服务后按 {@link ApiConstants#NAME} 走真实 Feign。 + * + * @author 造梦AI + */ +@FeignClient(name = ApiConstants.NAME) +@Tag(name = "RPC 服务 - 合规锁风门 Gate") +public interface ComplianceGateApi { + + String PREFIX = ApiConstants.PREFIX; + + /** + * 锁风门评估:聚合风格/IP 合规原子产出 verdict(供 project.publish 同进程注入) + * + * 聚合语义:verdict = max(各原子),block > review > pass;同时落 game_compliance_gate_result 台账与 game_content_rating 分级。 + * 同步、非幂等写台账(每次发布评估各落一条审计记录,供溯源)。block 仍正常返回 verdict(不抛异常,由 project 侧据此置 admitted=false)。 + * + * @param reqDTO 评估入参(gameId/versionId 必填,title/summary/ageRating/promptHash 供原子参考) + * @return 聚合裁决(CommonResult 包裹) + */ + @PostMapping(PREFIX + "/evaluate") + @Operation(summary = "锁风门评估(project.publish 注入用)") + CommonResult evaluate(@RequestBody ComplianceGateReqDTO reqDTO); + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/ComplianceGateItemDTO.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/ComplianceGateItemDTO.java new file mode 100644 index 00000000..58388b78 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/ComplianceGateItemDTO.java @@ -0,0 +1,29 @@ +package cn.wanxiang.game.module.compliance.dto; + +import lombok.Data; + +/** + * 锁风门单原子裁决明细(对应 compliance.yaml ComplianceGateItemDTO) + * + * 每个合规原子(风格内容/图像/IP)产出一条明细,聚合后挂在 {@link GateVerdict#getDetail()}。 + * 供 project 侧展示「哪条原子拦截/复核」,以及 admin 复核溯源。 + * + * @author 造梦AI + */ +@Data +public class ComplianceGateItemDTO { + + /** + * 原子标识:textContent 文本内容 / imageContent 图像内容 / ip-reserved IP 保留位 + */ + private String atom; + /** + * 该原子裁决:pass/review/block(与 ComplianceVerdictEnum 编码一致) + */ + private String verdict; + /** + * 裁决原因(命中规则说明;pass 可空) + */ + private String reason; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/ComplianceGateReqDTO.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/ComplianceGateReqDTO.java new file mode 100644 index 00000000..f130f47c --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/ComplianceGateReqDTO.java @@ -0,0 +1,41 @@ +package cn.wanxiang.game.module.compliance.dto; + +import lombok.Data; + +/** + * 锁风门评估入参(project.publish → compliance seam,对应 compliance.yaml ComplianceGateReqDTO) + * + * project.submitPublish 注入 ComplianceGateApi.evaluate 时传入;compliance 聚合各风格/IP 原子裁决后返回 {@link GateVerdict}。 + * gameId/versionId 为定位锚点(落 game_compliance_gate_result);title/summary/ageRating/promptHash 供风格原子参考(MVP 桩默认 pass)。 + * + * @author 造梦AI + */ +@Data +public class ComplianceGateReqDTO { + + /** + * 游戏项目 ID(game_project.id;锁风门定位锚点) + */ + private Long gameId; + /** + * 待发布版本 ID(落 game_compliance_gate_result.version_id) + */ + private Long versionId; + /** + * 标题(风格内容原子参考;可空) + */ + private String title; + /** + * 摘要/简介(风格内容原子参考;可空) + */ + private String summary; + /** + * 适龄分级建议:all/8+/12+/16+(落 game_content_rating.rating;可空) + */ + private String ageRating; + /** + * 生成 Prompt 摘要哈希(合规溯源用;可空) + */ + private String promptHash; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/GateVerdict.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/GateVerdict.java new file mode 100644 index 00000000..dfe7d38e --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/dto/GateVerdict.java @@ -0,0 +1,32 @@ +package cn.wanxiang.game.module.compliance.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 锁风门聚合裁决(compliance → project.publish seam 出参,对应 compliance.yaml GateVerdict) + * + * = 草案 ComplianceGateRespDTO(R1 命名对齐为 GateVerdict)。 + * verdict 为聚合结果 = max(各原子),block > review > pass;detail 为各原子明细;rating 为本次评估落定的内容分级。 + * project.submitPublish 据 verdict 映射为 key=compliance 的 GateResultVO(R1 唯一注入点)。 + * + * @author 造梦AI + */ +@Data +public class GateVerdict { + + /** + * 聚合裁决:pass/review/block(与 ComplianceVerdictEnum 编码一致) + */ + private String verdict; + /** + * 内容分级:all/8+/12+/16+(本次评估落定,可空) + */ + private String rating; + /** + * 各合规原子明细(风格/图像/IP 逐条裁决) + */ + private List detail; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ApiConstants.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ApiConstants.java new file mode 100644 index 00000000..2ccf277e --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ApiConstants.java @@ -0,0 +1,33 @@ +package cn.wanxiang.game.module.compliance.enums; + +import cn.iocoder.yudao.framework.common.enums.RpcConstants; + +/** + * compliance 模块 API 相关枚举(Feign 服务名 + RPC 前缀) + * + * 仿 yudao system 的 {@code ApiConstants} 风格(克隆 ad 黄金模板)。 + * - NAME:Feign 目标服务名。MVP 为单体(spring.application.name=yudao-server,所有模块编入一个 JAR), + * 同进程内调用由本模块 {@code ComplianceGateApiImpl}(@RestController @Primary) 就地解析(与 yudao DictDataApi 同构); + * 拆分微服务后该值即对应 compliance 服务的 spring.application.name。 + * - PREFIX:RPC 接口前缀 /rpc-api/compliance,与业务端 /app-api、/admin-api 隔离,避免与业务路由冲突。 + * + * @author 造梦AI + */ +public class ApiConstants { + + /** + * 服务名(拆微服务后需与 compliance 服务的 spring.application.name 一致;MVP 单体下仅作 Feign 客户端名占位) + */ + public static final String NAME = "compliance-server"; + + /** + * RPC API 前缀(/rpc-api/compliance) + */ + public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/compliance"; + + /** + * 接口版本 + */ + public static final String VERSION = "1.0.0"; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/BanStatusEnum.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/BanStatusEnum.java new file mode 100644 index 00000000..3bbc88f4 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/BanStatusEnum.java @@ -0,0 +1,37 @@ +package cn.wanxiang.game.module.compliance.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 封禁状态机枚举(对应表 game_user_ban.status,tinyint) + * + * 合法流转:1 生效 → 0 已解封(解封由 admin 触发)。合法性由 Service 校验,DO 层不承载。 + * + * @author 造梦AI + */ +@Getter +@AllArgsConstructor +public enum BanStatusEnum { + + CANCELLED(0, "已解封"), + ACTIVE(1, "生效"); + + /** 状态值(落库 tinyint) */ + private final Integer status; + /** 状态名(展示用) */ + private final String name; + + /** + * 是否生效中 + * + * @param status 待判断状态值 + * @return true=生效中 + */ + public static boolean isActive(Integer status) { + return Objects.equals(ACTIVE.status, status); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/BanTargetTypeEnum.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/BanTargetTypeEnum.java new file mode 100644 index 00000000..47e2c531 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/BanTargetTypeEnum.java @@ -0,0 +1,48 @@ +package cn.wanxiang.game.module.compliance.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 封禁目标类型枚举(对应表 game_user_ban.target_type,tinyint) + * + * 1 游戏(target_id=game_id,封禁可联动下架)/ 2 用户(target_id=user_id)。 + * R3:仅 GAME 类型且 game.status==PUBLISHED 时才联动 project 下架状态机(本波标 TODO)。 + * + * @author 造梦AI + */ +@Getter +@AllArgsConstructor +public enum BanTargetTypeEnum { + + GAME(1, "游戏"), + USER(2, "用户"); + + /** 类型值(落库 tinyint) */ + private final Integer type; + /** 类型名(展示用) */ + private final String name; + + /** + * 是否为游戏目标(仅游戏目标可联动下架) + * + * @param type 待判断类型值 + * @return true=游戏 + */ + public static boolean isGame(Integer type) { + return Objects.equals(GAME.type, type); + } + + /** + * 校验类型值是否合法(仅允许 1 游戏 / 2 用户) + * + * @param type 待判断类型值 + * @return true=合法 + */ + public static boolean isValid(Integer type) { + return Objects.equals(GAME.type, type) || Objects.equals(USER.type, type); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ComplianceVerdictEnum.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ComplianceVerdictEnum.java new file mode 100644 index 00000000..5833dd21 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ComplianceVerdictEnum.java @@ -0,0 +1,70 @@ +package cn.wanxiang.game.module.compliance.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 合规裁决枚举(锁风门 Gate 聚合结果) + * + * 三态:pass 通过 / review 需人工复核 / block 拦截。 + * 聚合语义(Service 固化、可单测):verdict = max(各原子),严重度 block(2) > review(1) > pass(0)。 + * 在 project.publish 注入时映射(R1):block → 该 gate pass=false + admitted=false(不抛异常);pass/review → gate pass=true 进 REVIEWING。 + * + * @author 造梦AI + */ +@Getter +@AllArgsConstructor +public enum ComplianceVerdictEnum { + + PASS("pass", 0, "通过"), + REVIEW("review", 1, "需人工复核"), + BLOCK("block", 2, "拦截"); + + /** 裁决编码(落库 game_compliance_gate_result.verdict / 契约 GateVerdict.verdict) */ + private final String verdict; + /** 严重度(聚合 max 用,越大越严重) */ + private final Integer severity; + /** 裁决名(展示用) */ + private final String name; + + /** + * 两裁决取更严重者(聚合用,block > review > pass) + * + * @param a 裁决 A(可空,空视为 PASS) + * @param b 裁决 B(可空,空视为 PASS) + * @return 更严重的裁决 + */ + public static ComplianceVerdictEnum max(ComplianceVerdictEnum a, ComplianceVerdictEnum b) { + ComplianceVerdictEnum x = a == null ? PASS : a; + ComplianceVerdictEnum y = b == null ? PASS : b; + return x.severity >= y.severity ? x : y; + } + + /** + * 按编码查枚举 + * + * @param verdict 裁决编码 + * @return 枚举;非法值返回 null + */ + public static ComplianceVerdictEnum of(String verdict) { + for (ComplianceVerdictEnum e : values()) { + if (Objects.equals(e.verdict, verdict)) { + return e; + } + } + return null; + } + + /** + * 是否为拦截(block) + * + * @param verdict 裁决编码 + * @return true=block + */ + public static boolean isBlock(String verdict) { + return Objects.equals(BLOCK.verdict, verdict); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ErrorCodeConstants.java b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ErrorCodeConstants.java new file mode 100644 index 00000000..ce8e6163 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-api/src/main/java/cn/wanxiang/game/module/compliance/enums/ErrorCodeConstants.java @@ -0,0 +1,34 @@ +package cn.wanxiang.game.module.compliance.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * compliance 模块错误码枚举类 + * + * compliance 模块独占 1-109-***-*** 段(见 contracts/api-schemas/compliance.yaml 头部 + contracts/README.md §4 段位表)。 + * 约定:禁止与其它模块错误码段重叠(100 project/101 aigc/102 runtime/103 feed/104 telemetry/106 trade/109 compliance/111 ad/112 studio);新增错误码在此登记。 + * + * 注意(R1):publish-blocked(发布被门禁拦截)错误码不在本类登记——block 时 admitted=false 不抛异常, + * 该文案由 project 侧 GateResultVO.code 携带;compliance 不重复登记 publish-blocked。 + * + * @author 造梦AI + */ +public interface ErrorCodeConstants { + + // ========== 锁风门 Gate 1-109-001-*** ========== + /** 锁风门:评估目标(游戏/版本)不存在 */ + ErrorCode COMPLIANCE_GATE_TARGET_NOT_FOUND = new ErrorCode(1_109_001_000, "锁风门:游戏/版本不存在"); + + // ========== 封禁台账 1-109-002-*** ========== + /** 封禁:目标类型非法(仅允许 1 游戏 / 2 用户) */ + ErrorCode COMPLIANCE_BAN_TARGET_INVALID = new ErrorCode(1_109_002_000, "封禁:目标类型非法"); + /** 封禁:该目标已存在生效中的封禁记录(uk_target_active 查重) */ + ErrorCode COMPLIANCE_BAN_ALREADY_EXISTS = new ErrorCode(1_109_002_001, "封禁:该目标已被封禁"); + /** 解封:封禁记录不存在(按 banId 查不到或已解封) */ + ErrorCode COMPLIANCE_BAN_NOT_EXISTS = new ErrorCode(1_109_002_002, "解封:封禁记录不存在"); + + // ========== 内容分级 1-109-003-*** ========== + /** 分级:无分级记录(按 game_id+version_id 查不到) */ + ErrorCode COMPLIANCE_RATING_NOT_FOUND = new ErrorCode(1_109_003_000, "分级:无分级记录"); + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/pom.xml b/game-cloud/game-module-compliance/game-module-compliance-server/pom.xml new file mode 100644 index 00000000..633f1976 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/pom.xml @@ -0,0 +1,87 @@ + + + + cn.iocoder.cloud + game-module-compliance + ${revision} + + 4.0.0 + game-module-compliance-server + jar + + ${project.artifactId} + + compliance 模块业务实现:controller(app/admin) / service(gate·ban) / dal(DO·Mapper) / convert / 合规原子(StyleComplianceAtom·IpComplianceSeam)。 + 被 yudao-server 聚合启动(随单体一起加载,无需独立 bootstrap)。 + + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-env + + + + + cn.iocoder.cloud + game-module-compliance-api + ${revision} + + + + + cn.iocoder.cloud + game-module-project-api + ${revision} + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-biz-data-permission + + + cn.iocoder.cloud + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-security + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-mybatis + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-redis + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-rpc + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-test + test + + + + + ${project.artifactId} + + + diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/api/ComplianceGateApiImpl.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/api/ComplianceGateApiImpl.java new file mode 100644 index 00000000..673e2a83 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/api/ComplianceGateApiImpl.java @@ -0,0 +1,35 @@ +package cn.wanxiang.game.module.compliance.api; + +import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO; +import cn.wanxiang.game.module.compliance.dto.GateVerdict; +import cn.wanxiang.game.module.compliance.service.gate.ComplianceGateService; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Primary; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 合规锁风门 Gate API 实现(提供 RESTful 接口给 project.publish 注入调用,对应 compliance.yaml) + * + * @RestController + @Primary:与 yudao DictDataApiImpl 同构——单体内同进程调用走本实现,拆微服务后走 Feign。 + * 仅委托 {@link ComplianceGateService},不写业务逻辑(聚合/落库在 Service)。 + * + * @author 造梦AI + */ +@RestController // 提供 RESTful API 接口,给 Feign 调用 +@Validated +@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析) +public class ComplianceGateApiImpl implements ComplianceGateApi { + + @Resource + private ComplianceGateService complianceGateService; + + @Override + public CommonResult evaluate(ComplianceGateReqDTO reqDTO) { + return success(complianceGateService.evaluate(reqDTO)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/ComplianceBanController.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/ComplianceBanController.java new file mode 100644 index 00000000..294a2273 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/ComplianceBanController.java @@ -0,0 +1,66 @@ +package cn.wanxiang.game.module.compliance.controller.admin; + +import cn.wanxiang.game.module.compliance.controller.admin.vo.BanCancelReqVO; +import cn.wanxiang.game.module.compliance.controller.admin.vo.BanCreateReqVO; +import cn.wanxiang.game.module.compliance.controller.admin.vo.BanPageReqVO; +import cn.wanxiang.game.module.compliance.controller.admin.vo.UserBanRespVO; +import cn.wanxiang.game.module.compliance.convert.ComplianceConvert; +import cn.wanxiang.game.module.compliance.dal.dataobject.ban.UserBanDO; +import cn.wanxiang.game.module.compliance.service.ban.UserBanService; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 管理后台(game-admin)- 合规封禁控制器 + * + * 端前缀 /admin-api 由框架按包名 controller.admin.* 自动添加。 + * 鉴权:RBAC,@PreAuthorize 注解 + 网关双重校验。 + * R3:封禁写台账=主操作恒执行;下架联动本波 TODO 留 Phase C(见 UserBanServiceImpl)。 + * + * @author 造梦AI + */ +@Tag(name = "管理后台 - 合规封禁") +@RestController +@RequestMapping("/compliance/ban") +@Validated +public class ComplianceBanController { + + @Resource + private UserBanService userBanService; + + @PostMapping("/create") + @Operation(summary = "创建封禁", description = "写 game_user_ban 台账(主操作恒执行);游戏目标下架联动按 project 状态分支") + @PreAuthorize("@ss.hasPermission('compliance:ban:create')") + public CommonResult createBan(@Valid @RequestBody BanCreateReqVO reqVO) { + // operatorUserId = 当前登录的 admin 用户(落 creator 审计 + 操作溯源) + Long operatorUserId = SecurityFrameworkUtils.getLoginUserId(); + return success(userBanService.createBan(reqVO, operatorUserId)); + } + + @PostMapping("/cancel") + @Operation(summary = "解封", description = "status 1→0;记录不存在或已解封报错") + @PreAuthorize("@ss.hasPermission('compliance:ban:cancel')") + public CommonResult cancelBan(@Valid @RequestBody BanCancelReqVO reqVO) { + userBanService.cancelBan(reqVO.getBanId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "封禁台账分页", description = "按目标类型/状态筛选") + @PreAuthorize("@ss.hasPermission('compliance:ban:query')") + public CommonResult> getBanPage(@Valid BanPageReqVO reqVO) { + PageResult pageResult = userBanService.getBanPage(reqVO); + return success(ComplianceConvert.toBanRespPage(pageResult)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanCancelReqVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanCancelReqVO.java new file mode 100644 index 00000000..0105938f --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanCancelReqVO.java @@ -0,0 +1,22 @@ +package cn.wanxiang.game.module.compliance.controller.admin.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 解封入参 VO(admin-api,对齐 compliance.yaml /admin-api/compliance/ban/cancel) + * + * 按 banId 解封:status 1→0(合法流转由 Service 校验,记录不存在/已解封报 COMPLIANCE_BAN_NOT_EXISTS)。 + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 解封 Request VO") +@Data +public class BanCancelReqVO { + + @Schema(description = "封禁台账记录 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "封禁记录 ID 不能为空") + private Long banId; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanCreateReqVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanCreateReqVO.java new file mode 100644 index 00000000..9696139e --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanCreateReqVO.java @@ -0,0 +1,32 @@ +package cn.wanxiang.game.module.compliance.controller.admin.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 封禁创建入参 VO(admin-api,对齐 compliance.yaml /admin-api/compliance/ban/create) + * + * 落 game_user_ban 台账(主操作恒执行);R3 下架联动仅当 targetType=游戏 且 game.status==PUBLISHED 时才调 project 状态机(本波标 TODO)。 + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 封禁创建 Request VO") +@Data +public class BanCreateReqVO { + + @Schema(description = "封禁目标类型:1 游戏 / 2 用户", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "封禁目标类型不能为空") + private Integer targetType; + + @Schema(description = "封禁目标 ID(游戏=game_id / 用户=user_id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "封禁目标 ID 不能为空") + private Long targetId; + + @Schema(description = "处置类型:1 封禁 / 2 降权(P1 本波不生效)", example = "1") + private Integer banType; + + @Schema(description = "封禁/降权原因", example = "违规内容,违反社区规范") + private String reason; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanPageReqVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanPageReqVO.java new file mode 100644 index 00000000..d46dd581 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/BanPageReqVO.java @@ -0,0 +1,26 @@ +package cn.wanxiang.game.module.compliance.controller.admin.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 封禁台账分页 Request VO(admin,对齐 compliance.yaml /admin-api/compliance/ban/page) + * + * 分页参数复用 Yudao PageParam(pageNo/pageSize);可选按目标类型/状态筛选(命中 idx_type_status)。 + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 封禁台账分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class BanPageReqVO extends PageParam { + + @Schema(description = "封禁目标类型过滤:1 游戏 / 2 用户", example = "1") + private Integer targetType; + + @Schema(description = "封禁状态过滤:1 生效 / 0 已解封", example = "1") + private Integer status; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/UserBanRespVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/UserBanRespVO.java new file mode 100644 index 00000000..efd37d25 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/admin/vo/UserBanRespVO.java @@ -0,0 +1,38 @@ +package cn.wanxiang.game.module.compliance.controller.admin.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 封禁台账 Response VO(admin,对齐 compliance.yaml /admin-api/compliance/ban/page 项) + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 封禁台账 Response VO") +@Data +public class UserBanRespVO { + + @Schema(description = "封禁台账记录 ID", example = "1") + private Long id; + + @Schema(description = "封禁目标类型:1 游戏 / 2 用户", example = "1") + private Integer targetType; + + @Schema(description = "封禁目标 ID", example = "1024") + private Long targetId; + + @Schema(description = "处置类型:1 封禁 / 2 降权", example = "1") + private Integer banType; + + @Schema(description = "封禁状态:1 生效 / 0 已解封", example = "1") + private Integer status; + + @Schema(description = "封禁/降权原因", example = "违规内容") + private String reason; + + @Schema(description = "封禁时间", example = "2026-06-09 10:00:00") + private LocalDateTime createTime; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/ComplianceRatingController.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/ComplianceRatingController.java new file mode 100644 index 00000000..0a4ffbf4 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/ComplianceRatingController.java @@ -0,0 +1,47 @@ +package cn.wanxiang.game.module.compliance.controller.app; + +import cn.wanxiang.game.module.compliance.controller.app.vo.ContentRatingRespVO; +import cn.wanxiang.game.module.compliance.convert.ComplianceConvert; +import cn.wanxiang.game.module.compliance.dal.dataobject.rating.ContentRatingDO; +import cn.wanxiang.game.module.compliance.service.gate.ContentRatingService; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +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 org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 产品端(game-studio)- 内容分级控制器(只读) + * + * 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。 + * 鉴权:分级只读,用户/匿名 Token 可访问。分级写入仅由 Gate evaluate 落定 + admin 复核产生(本控制器不写)。 + * + * @author 造梦AI + */ +@Tag(name = "用户 App - 内容分级") +@RestController +@RequestMapping("/compliance/rating") +@Validated +public class ComplianceRatingController { + + @Resource + private ContentRatingService contentRatingService; + + @GetMapping("/get") + @Operation(summary = "取内容分级", description = "按游戏+版本读分级(all/8+/12+/16+);无记录报 COMPLIANCE_RATING_NOT_FOUND") + @Parameter(name = "gameId", description = "游戏 ID", required = true, example = "1024") + @Parameter(name = "versionId", description = "版本 ID(无版本传 0)", example = "0") + public CommonResult getRating(@RequestParam("gameId") Long gameId, + @RequestParam(value = "versionId", required = false) Long versionId) { + ContentRatingDO rating = contentRatingService.getRating(gameId, versionId); + return success(ComplianceConvert.toRatingRespVO(rating)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/vo/ContentRatingRespVO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/vo/ContentRatingRespVO.java new file mode 100644 index 00000000..723826d1 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/controller/app/vo/ContentRatingRespVO.java @@ -0,0 +1,28 @@ +package cn.wanxiang.game.module.compliance.controller.app.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 内容分级 Response VO(app-api 只读,对齐 compliance.yaml /app-api/compliance/rating/get) + * + * 产品端据此展示游戏适龄分级(all/8+/12+/16+)。分级写入仅由 Gate evaluate 落定 + admin 复核产生。 + * + * @author 造梦AI + */ +@Schema(description = "用户 App - 内容分级 Response VO") +@Data +public class ContentRatingRespVO { + + @Schema(description = "内容分级:all 全年龄/8+/12+/16+", example = "all") + private String rating; + + @Schema(description = "分级来源:gate 锁风门自动/admin 人工复核", example = "gate") + private String ratedBy; + + @Schema(description = "分级更新时间", example = "2026-06-09 10:00:00") + private LocalDateTime updateTime; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/convert/ComplianceConvert.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/convert/ComplianceConvert.java new file mode 100644 index 00000000..a038b56a --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/convert/ComplianceConvert.java @@ -0,0 +1,55 @@ +package cn.wanxiang.game.module.compliance.convert; + +import cn.wanxiang.game.module.compliance.controller.admin.vo.UserBanRespVO; +import cn.wanxiang.game.module.compliance.controller.app.vo.ContentRatingRespVO; +import cn.wanxiang.game.module.compliance.dal.dataobject.ban.UserBanDO; +import cn.wanxiang.game.module.compliance.dal.dataobject.rating.ContentRatingDO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * compliance 模块转换器(DO ↔ VO) + * + * 集中承载封禁台账 / 内容分级的 DO → VO 转换(字段同名直拷,BeanUtils.toBean)。 + * + * @author 造梦AI + */ +public class ComplianceConvert { + + /** + * DO → admin 封禁台账 VO + * + * @param ban 封禁 DO + * @return 封禁 VO;入参 null 返回 null + */ + public static UserBanRespVO toBanRespVO(UserBanDO ban) { + return BeanUtils.toBean(ban, UserBanRespVO.class); + } + + /** + * 分页 DO → 分页 admin 封禁台账 VO + * + * @param page 分页 DO + * @return 分页 VO + */ + public static PageResult toBanRespPage(PageResult page) { + List list = page.getList().stream() + .map(ComplianceConvert::toBanRespVO) + .collect(Collectors.toList()); + return new PageResult<>(list, page.getTotal()); + } + + /** + * DO → app 内容分级 VO + * + * @param rating 分级 DO + * @return 分级 VO;入参 null 返回 null + */ + public static ContentRatingRespVO toRatingRespVO(ContentRatingDO rating) { + return BeanUtils.toBean(rating, ContentRatingRespVO.class); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/ban/UserBanDO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/ban/UserBanDO.java new file mode 100644 index 00000000..c5a452dc --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/ban/UserBanDO.java @@ -0,0 +1,51 @@ +package cn.wanxiang.game.module.compliance.dal.dataobject.ban; + +import cn.iocoder.yudao.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_user_ban) + * + * 继承 {@link TenantBaseDO}:自动携带审计/租户列(creator=封禁操作的 admin userId)。 + * 唯一键 uk_target_active=(target_type,target_id,status,deleted,tenant_id):同目标 status=1 不可重复生效封禁。 + * 状态机 status:1 生效 → 0 已解封(合法流转由 Service 校验,DO 层不承载)。 + * R3:封禁写台账=主操作恒执行;下架联动按 project 状态分支(仅 PUBLISHED 调状态机,本波标 TODO)。 + * R5:ban_type=2 降权出流屏蔽 P1 本波不生效(不建 feed seam)。 + * + * @author 造梦AI + */ +@TableName("game_user_ban") +@KeySequence("game_user_ban_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略 +@Data +@EqualsAndHashCode(callSuper = true) +public class UserBanDO extends TenantBaseDO { + + /** + * 封禁台账记录 ID + */ + private Long id; + /** + * 封禁目标类型:1 游戏 / 2 用户 + */ + private Integer targetType; + /** + * 封禁目标 ID(游戏=game_id / 用户=user_id) + */ + private Long targetId; + /** + * 处置类型:1 封禁(下架/禁用)/ 2 降权(出流屏蔽,P1 本波不生效) + */ + private Integer banType; + /** + * 封禁状态机:1 生效 / 0 已解封(合法流转由 Service 校验) + */ + private Integer status; + /** + * 封禁/降权原因(合规处置说明) + */ + private String reason; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/gate/GateResultDO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/gate/GateResultDO.java new file mode 100644 index 00000000..54b3ecf1 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/gate/GateResultDO.java @@ -0,0 +1,53 @@ +package cn.wanxiang.game.module.compliance.dal.dataobject.gate; + +import cn.iocoder.yudao.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_compliance_gate_result) + * + * 继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列。 + * 写口径:每次 publish 评估各落一条(非幂等审计记录);verdict 为聚合结果 max(各原子),details 为各原子明细 JSON。 + * 裁决合法性/聚合逻辑由 Service 承载,DO 层只承载数据。 + * + * @author 造梦AI + */ +@TableName("game_compliance_gate_result") +@KeySequence("game_compliance_gate_result_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略 +@Data +@EqualsAndHashCode(callSuper = true) +public class GateResultDO extends TenantBaseDO { + + /** + * 锁风门评估记录 ID + */ + private Long id; + /** + * 评估的游戏 ID(project.game_project.id) + */ + private Long gameId; + /** + * 评估的版本 ID(待发布版本;无版本时为 0) + */ + private Long versionId; + /** + * 聚合裁决:pass/review/block(= max(各原子),与 ComplianceVerdictEnum 编码一致) + */ + private String verdict; + /** + * 本次评估落定的内容分级:all/8+/12+/16+(可空) + */ + private String rating; + /** + * 各合规原子明细 JSON([{atom,verdict,reason}],便于复核溯源) + */ + private String details; + /** + * 全链路追踪 ID(与 publish 链路对账) + */ + private String traceId; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/rating/ContentRatingDO.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/rating/ContentRatingDO.java new file mode 100644 index 00000000..cadf3191 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/dataobject/rating/ContentRatingDO.java @@ -0,0 +1,45 @@ +package cn.wanxiang.game.module.compliance.dal.dataobject.rating; + +import cn.iocoder.yudao.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_content_rating) + * + * 继承 {@link TenantBaseDO}:自动携带审计/租户列。 + * 唯一键 uk_game_version=(game_id,version_id,deleted,tenant_id):同游戏同版本只存一条最新分级。 + * 分级写入仅由 Gate evaluate 落定 + admin 复核产生;/app-api/compliance/rating/get 只读取。 + * + * @author 造梦AI + */ +@TableName("game_content_rating") +@KeySequence("game_content_rating_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略 +@Data +@EqualsAndHashCode(callSuper = true) +public class ContentRatingDO extends TenantBaseDO { + + /** + * 内容分级记录 ID + */ + private Long id; + /** + * 游戏 ID(project.game_project.id) + */ + private Long gameId; + /** + * 版本 ID(无版本时为 0) + */ + private Long versionId; + /** + * 内容分级:all 全年龄/8+/12+/16+ + */ + private String rating; + /** + * 分级来源:gate 锁风门自动/admin 人工复核 + */ + private String ratedBy; + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/ContentRatingMapper.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/ContentRatingMapper.java new file mode 100644 index 00000000..1671c4bd --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/ContentRatingMapper.java @@ -0,0 +1,31 @@ +package cn.wanxiang.game.module.compliance.dal.mysql; + +import cn.wanxiang.game.module.compliance.dal.dataobject.rating.ContentRatingDO; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import org.apache.ibatis.annotations.Mapper; + +/** + * 内容分级 Mapper + * + * 显式列查询、命中 uk_game_version(禁裸 select*)。 + * + * @author 造梦AI + */ +@Mapper +public interface ContentRatingMapper extends BaseMapperX { + + /** + * 按游戏+版本查内容分级(命中 uk_game_version;tenant_id/deleted 由 Yudao 拦截器自动追加) + * + * @param gameId 游戏 ID + * @param versionId 版本 ID(无版本传 0) + * @return 分级 DO;不存在返回 null + */ + default ContentRatingDO selectByGameVersion(Long gameId, Long versionId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ContentRatingDO::getGameId, gameId) + .eq(ContentRatingDO::getVersionId, versionId)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/GateResultMapper.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/GateResultMapper.java new file mode 100644 index 00000000..a780db41 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/GateResultMapper.java @@ -0,0 +1,17 @@ +package cn.wanxiang.game.module.compliance.dal.mysql; + +import cn.wanxiang.game.module.compliance.dal.dataobject.gate.GateResultDO; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import org.apache.ibatis.annotations.Mapper; + +/** + * 锁风门评估台账 Mapper + * + * 仅追加写(每次评估各落一条审计记录),无业务唯一去重;查询命中 idx_game_version(按游戏+版本溯源)。 + * + * @author 造梦AI + */ +@Mapper +public interface GateResultMapper extends BaseMapperX { + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/UserBanMapper.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/UserBanMapper.java new file mode 100644 index 00000000..e103b861 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/dal/mysql/UserBanMapper.java @@ -0,0 +1,52 @@ +package cn.wanxiang.game.module.compliance.dal.mysql; + +import cn.wanxiang.game.module.compliance.controller.admin.vo.BanPageReqVO; +import cn.wanxiang.game.module.compliance.dal.dataobject.ban.UserBanDO; +import cn.wanxiang.game.module.compliance.enums.BanStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import org.apache.ibatis.annotations.Mapper; + +/** + * 封禁台账 Mapper + * + * 显式列查询、命中索引(禁裸 select*): + * - selectActiveByTarget 命中 uk_target_active(封禁查重,防同目标重复生效封禁); + * - 分页命中 idx_type_status。 + * + * @author 造梦AI + */ +@Mapper +public interface UserBanMapper extends BaseMapperX { + + /** + * 按目标查生效中(status=1)的封禁记录(命中 uk_target_active;封禁查重) + * + * tenant_id/deleted 由 Yudao 拦截器自动追加到 WHERE,不在此显式拼。 + * + * @param targetType 目标类型(1 游戏 / 2 用户) + * @param targetId 目标 ID + * @return 生效中封禁 DO;不存在返回 null + */ + default UserBanDO selectActiveByTarget(Integer targetType, Long targetId) { + return selectOne(new LambdaQueryWrapperX() + .eq(UserBanDO::getTargetType, targetType) + .eq(UserBanDO::getTargetId, targetId) + .eq(UserBanDO::getStatus, BanStatusEnum.ACTIVE.getStatus())); + } + + /** + * 封禁台账分页(admin,按目标类型/状态筛选);命中 idx_type_status + * + * @param reqVO 分页 + 可选筛选 + * @return 分页结果 + */ + default PageResult selectPage(BanPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(UserBanDO::getTargetType, reqVO.getTargetType()) + .eqIfPresent(UserBanDO::getStatus, reqVO.getStatus()) + .orderByDesc(UserBanDO::getId)); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/ComplianceAtomResult.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/ComplianceAtomResult.java new file mode 100644 index 00000000..0a689a46 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/ComplianceAtomResult.java @@ -0,0 +1,41 @@ +package cn.wanxiang.game.module.compliance.framework.atom; + +import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum; +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 合规原子裁决结果(单个合规原子的产出,供 GateService 聚合) + * + * GateService 聚合各原子 result.verdict = max(...),并把 atom/verdict/reason 拼为 GateVerdict.detail 明细。 + * + * @author 造梦AI + */ +@Data +@AllArgsConstructor +public class ComplianceAtomResult { + + /** + * 原子标识:textContent 文本内容 / imageContent 图像内容 / ip-reserved IP 保留位 + */ + private String atom; + /** + * 该原子裁决(pass/review/block) + */ + private ComplianceVerdictEnum verdict; + /** + * 裁决原因(命中规则说明;pass 可空) + */ + private String reason; + + /** + * 构造一个 pass 结果(桩默认/无命中规则时用) + * + * @param atom 原子标识 + * @return pass 结果 + */ + public static ComplianceAtomResult pass(String atom) { + return new ComplianceAtomResult(atom, ComplianceVerdictEnum.PASS, null); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/IpComplianceSeam.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/IpComplianceSeam.java new file mode 100644 index 00000000..43c285f6 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/IpComplianceSeam.java @@ -0,0 +1,31 @@ +package cn.wanxiang.game.module.compliance.framework.atom; + +import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO; +import org.springframework.stereotype.Component; + +/** + * IP 合规扩展点(D5:ip 不独立建模块,仅留 seam;桩实现,默认 pass) + * + * D5 决策:IP 侵权检测本波不独立建模块、仅在 compliance 内留扩展点。MVP 桩恒返回 pass。 + * TODO(Wave4):接 ip 模块(错误码段 108)后只换本实现,GateService 聚合逻辑不变(聚合预留本原子)。 + * + * @author 造梦AI + */ +@Component +public class IpComplianceSeam { + + /** 原子标识(落 GateVerdict.detail) */ + public static final String ATOM = "ip-reserved"; + + /** + * 评估 IP 合规(D5 桩:默认 pass) + * + * @param reqDTO 评估入参(title/summary 供未来 IP 比对;本波桩不读取做裁决) + * @return 原子裁决(桩恒 pass) + */ + public ComplianceAtomResult evaluate(ComplianceGateReqDTO reqDTO) { + // TODO(Wave4):接 ip 模块做真实 IP 侵权比对(D5 仅留 seam),届时只换本实现 + return ComplianceAtomResult.pass(ATOM); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/StyleComplianceAtom.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/StyleComplianceAtom.java new file mode 100644 index 00000000..70c1c668 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/framework/atom/StyleComplianceAtom.java @@ -0,0 +1,32 @@ +package cn.wanxiang.game.module.compliance.framework.atom; + +import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO; +import org.springframework.stereotype.Component; + +/** + * 风格合规原子(文本内容/风格扫描;桩实现,默认 pass) + * + * D2/D5 范围:MVP aigc 侧尚无 T-AGC-19(风格内容合规接口),故本原子为桩,恒返回 pass。 + * TODO(P1):接 aigc-api 风格内容合规 seam(aigc 补 T-AGC-19 后),将 title/summary/promptHash 透传给 aigc 做真实文本扫描, + * 把 aigc 返回的命中规则映射为 review/block。本波不引入 aigc-api 依赖(避免孤儿调用)。 + * + * @author 造梦AI + */ +@Component +public class StyleComplianceAtom { + + /** 原子标识(落 GateVerdict.detail) */ + public static final String ATOM = "textContent"; + + /** + * 评估风格/文本内容合规(桩:默认 pass) + * + * @param reqDTO 评估入参(title/summary/promptHash 供未来真实扫描;本波桩不读取做裁决) + * @return 原子裁决(桩恒 pass) + */ + public ComplianceAtomResult evaluate(ComplianceGateReqDTO reqDTO) { + // TODO(P1):接 aigc 风格内容合规 seam(T-AGC-19),把 reqDTO.title/summary/promptHash 透传做真实扫描 + return ComplianceAtomResult.pass(ATOM); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanService.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanService.java new file mode 100644 index 00000000..286bf963 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanService.java @@ -0,0 +1,44 @@ +package cn.wanxiang.game.module.compliance.service.ban; + +import cn.wanxiang.game.module.compliance.controller.admin.vo.BanCreateReqVO; +import cn.wanxiang.game.module.compliance.controller.admin.vo.BanPageReqVO; +import cn.wanxiang.game.module.compliance.dal.dataobject.ban.UserBanDO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; + +/** + * 封禁台账 Service 接口(R3 状态分支) + * + * 承载:封禁/解封台账写入(主操作恒执行)+ 封禁查重 + 分页。 + * R3 下架联动:封禁游戏且 game.status==PUBLISHED 时才调 project 权威状态机下架——但 ProjectApi 本波尚无 reviewProject seam, + * 故下架联动本波标 TODO/openIssue(见 createBan 注释),只写台账,下架联动留 Phase C 主 agent 收口(不裸调不存在的方法、不自改 project-api)。 + * R5 降权出流屏蔽:P1 本波不生效(不调 feed、不建 FeedDownweightApi 孤儿 seam)。 + * + * @author 造梦AI + */ +public interface UserBanService { + + /** + * 创建封禁(写 game_user_ban 台账,主操作恒执行;查重防同目标重复生效封禁) + * + * @param reqVO 封禁入参 + * @param operatorUserId 操作的 admin 用户 ID(落 creator 审计) + * @return 新建封禁台账 ID + */ + Long createBan(BanCreateReqVO reqVO, Long operatorUserId); + + /** + * 解封(status 1→0;记录不存在或已解封报错) + * + * @param banId 封禁台账 ID + */ + void cancelBan(Long banId); + + /** + * 封禁台账分页(admin RBAC) + * + * @param reqVO 分页 + 筛选 + * @return 分页结果 + */ + PageResult getBanPage(BanPageReqVO reqVO); + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImpl.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImpl.java new file mode 100644 index 00000000..aa9f014e --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImpl.java @@ -0,0 +1,114 @@ +package cn.wanxiang.game.module.compliance.service.ban; + +import cn.wanxiang.game.module.compliance.controller.admin.vo.BanCreateReqVO; +import cn.wanxiang.game.module.compliance.controller.admin.vo.BanPageReqVO; +import cn.wanxiang.game.module.compliance.dal.dataobject.ban.UserBanDO; +import cn.wanxiang.game.module.compliance.dal.mysql.UserBanMapper; +import cn.wanxiang.game.module.compliance.enums.BanStatusEnum; +import cn.wanxiang.game.module.compliance.enums.BanTargetTypeEnum; +import cn.wanxiang.game.module.project.api.ProjectApi; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import static cn.wanxiang.game.module.compliance.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 封禁台账 Service 实现(R3 状态分支) + * + * 核心:封禁写 game_user_ban 台账=主操作恒执行(先查重再插入);解封 status 1→0。 + * + * 【R3 下架联动 / 本波边界 = openIssue】 + * 设计目标:封禁游戏且 game.status==PUBLISHED 时才调 project 权威状态机下架(reviewProject decision=3);非 PUBLISHED 只记台账不调状态机、不抛异常。 + * 现状:ProjectApi(Phase A)仅有 getCreatorUserId/getCurrentVersionId/createProject,【无 reviewProject seam、无 game 状态查询 seam】。 + * 决策(按执行 spec §4.5 + 任务硬约束):不裸调不存在的方法、不自改 project-api(那是主 agent Phase A 单点)。 + * 故本波只写 ban 台账,下架联动收敛在 {@link #maybeUnlistPublishedGame} 一处占位(TODO 明注),留 Phase C 主 agent 收口接 project 状态机。 + * R5 降权出流屏蔽:P1 本波不生效(不注入 feed、不建 FeedDownweightApi 孤儿 seam)。 + * + * @author 造梦AI + */ +@Slf4j +@Service +public class UserBanServiceImpl implements UserBanService { + + @Resource + private UserBanMapper userBanMapper; + + /** + * 依赖 project 的 -api(R3 下架联动收口点预留)。本波仅持有引用、不调用未就绪 seam; + * Phase C 主 agent 在 project-api 补 reviewProject/状态查询后,于 {@link #maybeUnlistPublishedGame} 接入。 + */ + @Resource + private ProjectApi projectApi; + + @Override + public Long createBan(BanCreateReqVO reqVO, Long operatorUserId) { + // 1) 目标类型合法校验(仅 1 游戏 / 2 用户) + if (!BanTargetTypeEnum.isValid(reqVO.getTargetType())) { + throw exception(COMPLIANCE_BAN_TARGET_INVALID); + } + // 2) 查重:同目标已有生效中(status=1)封禁则拒绝(命中 uk_target_active) + if (userBanMapper.selectActiveByTarget(reqVO.getTargetType(), reqVO.getTargetId()) != null) { + throw exception(COMPLIANCE_BAN_ALREADY_EXISTS); + } + // 3) 写封禁台账(主操作恒执行)——ban_type 缺省为 1 封禁 + UserBanDO ban = new UserBanDO(); + ban.setTargetType(reqVO.getTargetType()); + ban.setTargetId(reqVO.getTargetId()); + ban.setBanType(reqVO.getBanType() == null ? 1 : reqVO.getBanType()); + ban.setStatus(BanStatusEnum.ACTIVE.getStatus()); + ban.setReason(StringUtils.hasText(reqVO.getReason()) ? reqVO.getReason() : ""); + userBanMapper.insert(ban); + log.info("[createBan][封禁台账写入 banId={} targetType={} targetId={} operator={}]", + ban.getId(), reqVO.getTargetType(), reqVO.getTargetId(), operatorUserId); + + // 4) R3 下架联动(仅游戏目标):本波标 TODO/openIssue,只记台账不调状态机(见类注释) + if (BanTargetTypeEnum.isGame(reqVO.getTargetType())) { + maybeUnlistPublishedGame(reqVO.getTargetId()); + } + return ban.getId(); + } + + @Override + public void cancelBan(Long banId) { + // 解封:记录须存在且当前生效中(status=1);否则报不存在(含已解封) + UserBanDO ban = userBanMapper.selectById(banId); + if (ban == null || !BanStatusEnum.isActive(ban.getStatus())) { + throw exception(COMPLIANCE_BAN_NOT_EXISTS); + } + UserBanDO update = new UserBanDO(); + update.setId(banId); + update.setStatus(BanStatusEnum.CANCELLED.getStatus()); + userBanMapper.updateById(update); + log.info("[cancelBan][解封 banId={}]", banId); + } + + @Override + public PageResult getBanPage(BanPageReqVO reqVO) { + return userBanMapper.selectPage(reqVO); + } + + // ============================== R3 下架联动收口点(本波 TODO,留 Phase C) ============================== + + /** + * 封禁游戏的下架联动(R3 状态分支收口点;本波为 TODO 占位) + * + * 目标语义:仅当 game.status==PUBLISHED 时才调 project 权威状态机 reviewProject(decision=3) 走下架;非 PUBLISHED 只记封禁、不调状态机、不抛异常。 + * 本波现状:ProjectApi 无 reviewProject seam、无 game 状态查询 seam(Phase A 只加了 getCurrentVersionId/createProject)。 + * 按硬约束「不裸调不存在的方法、不自改 project-api」,本波不实现真实下架,仅记日志占位。 + * Phase C 主 agent 收口:在 project-api 补状态查询 + reviewProject seam 后,在此处接: + * Integer status = projectApi.getGameStatus(gameId).getCheckedData(); + * if (ProjectStatusEnum.PUBLISHED.getStatus().equals(status)) projectApi.reviewProject(gameId, REVIEW_UNLIST=3); + * + * @param gameId 被封禁游戏 ID + */ + private void maybeUnlistPublishedGame(Long gameId) { + // TODO(Phase C 主 agent 收口):接 project reviewProject 状态机下架——仅 PUBLISHED 调,非 PUBLISHED 只记台账不抛异常。 + // 本波因 ProjectApi 无 reviewProject/状态查询 seam,下架联动不生效(openIssue),ban 台账已写入。 + log.warn("[maybeUnlistPublishedGame][gameId={} 封禁台账已写入;下架联动 TODO 留 Phase C 收口(project-api 暂无 reviewProject seam)]", gameId); + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateService.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateService.java new file mode 100644 index 00000000..a82bdb71 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateService.java @@ -0,0 +1,27 @@ +package cn.wanxiang.game.module.compliance.service.gate; + +import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO; +import cn.wanxiang.game.module.compliance.dto.GateVerdict; + +/** + * 合规锁风门 Gate Service 接口 + * + * 承载:聚合各合规原子(风格/IP)→ verdict(max(各原子),block>review>pass)+ 落锁风门台账 + 落内容分级。 + * 仅做 Gate(D2),绝不重写 project 审核状态机。被 {@code ComplianceGateApiImpl} 委托(project.publish 注入用)。 + * + * @author 造梦AI + */ +public interface ComplianceGateService { + + /** + * 锁风门评估:聚合合规原子产出裁决,并落锁风门台账 + 内容分级 + * + * 聚合语义:verdict = max(各原子),block>review>pass;block 不抛异常,正常返回 verdict(由 project 侧据此置 admitted=false)。 + * 写副作用:落 game_compliance_gate_result(每次一条审计)+ upsert game_content_rating(分级落定)。 + * + * @param reqDTO 评估入参(gameId/versionId 必填) + * @return 聚合裁决(含 verdict/rating/detail) + */ + GateVerdict evaluate(ComplianceGateReqDTO reqDTO); + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateServiceImpl.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateServiceImpl.java new file mode 100644 index 00000000..a19178b0 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateServiceImpl.java @@ -0,0 +1,118 @@ +package cn.wanxiang.game.module.compliance.service.gate; + +import cn.wanxiang.game.module.compliance.dal.dataobject.gate.GateResultDO; +import cn.wanxiang.game.module.compliance.dal.dataobject.rating.ContentRatingDO; +import cn.wanxiang.game.module.compliance.dal.mysql.ContentRatingMapper; +import cn.wanxiang.game.module.compliance.dal.mysql.GateResultMapper; +import cn.wanxiang.game.module.compliance.dto.ComplianceGateItemDTO; +import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO; +import cn.wanxiang.game.module.compliance.dto.GateVerdict; +import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum; +import cn.wanxiang.game.module.compliance.framework.atom.ComplianceAtomResult; +import cn.wanxiang.game.module.compliance.framework.atom.IpComplianceSeam; +import cn.wanxiang.game.module.compliance.framework.atom.StyleComplianceAtom; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * 合规锁风门 Gate Service 实现 + * + * 聚合各合规原子(风格 {@link StyleComplianceAtom} + IP {@link IpComplianceSeam})→ verdict = max(各原子); + * 落锁风门台账 game_compliance_gate_result(每次一条审计)+ upsert 内容分级 game_content_rating。 + * 仅做 Gate(D2),不碰 project 审核状态机。可单测(聚合/落库均经 mock 验证)。 + * + * @author 造梦AI + */ +@Slf4j +@Service +public class ComplianceGateServiceImpl implements ComplianceGateService { + + @Resource + private StyleComplianceAtom styleComplianceAtom; + + @Resource + private IpComplianceSeam ipComplianceSeam; + + @Resource + private GateResultMapper gateResultMapper; + + @Resource + private ContentRatingMapper contentRatingMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public GateVerdict evaluate(ComplianceGateReqDTO reqDTO) { + // 1) 逐个执行合规原子(风格内容 + IP;MVP 均为桩默认 pass) + List atomResults = new ArrayList<>(); + atomResults.add(styleComplianceAtom.evaluate(reqDTO)); + atomResults.add(ipComplianceSeam.evaluate(reqDTO)); + + // 2) 聚合裁决:verdict = max(各原子),block > review > pass + ComplianceVerdictEnum aggregated = ComplianceVerdictEnum.PASS; + List details = new ArrayList<>(); + for (ComplianceAtomResult r : atomResults) { + aggregated = ComplianceVerdictEnum.max(aggregated, r.getVerdict()); + ComplianceGateItemDTO item = new ComplianceGateItemDTO(); + item.setAtom(r.getAtom()); + item.setVerdict(r.getVerdict() == null ? ComplianceVerdictEnum.PASS.getVerdict() : r.getVerdict().getVerdict()); + item.setReason(r.getReason()); + details.add(item); + } + + // 3) 落定内容分级(来源=gate;缺省 all) + String rating = StringUtils.hasText(reqDTO.getAgeRating()) ? reqDTO.getAgeRating() : "all"; + + // 4) 落锁风门台账(每次评估一条审计记录,非幂等) + GateResultDO gateResult = new GateResultDO(); + gateResult.setGameId(reqDTO.getGameId()); + gateResult.setVersionId(reqDTO.getVersionId() == null ? 0L : reqDTO.getVersionId()); + gateResult.setVerdict(aggregated.getVerdict()); + gateResult.setRating(rating); + gateResult.setDetails(JsonUtils.toJsonString(details)); + gateResult.setTraceId(StringUtils.hasText(reqDTO.getPromptHash()) ? reqDTO.getPromptHash() : ""); + gateResultMapper.insert(gateResult); + + // 5) upsert 内容分级(同游戏同版本只存一条最新分级) + upsertRating(reqDTO.getGameId(), gateResult.getVersionId(), rating); + + // 6) 组装出参 + GateVerdict verdict = new GateVerdict(); + verdict.setVerdict(aggregated.getVerdict()); + verdict.setRating(rating); + verdict.setDetail(details); + log.info("[evaluate][锁风门评估完成 gameId={} versionId={} verdict={}]", + reqDTO.getGameId(), gateResult.getVersionId(), aggregated.getVerdict()); + return verdict; + } + + /** + * upsert 内容分级(存在则更新 rating,不存在则插入;来源 gate) + * + * @param gameId 游戏 ID + * @param versionId 版本 ID + * @param rating 分级 + */ + private void upsertRating(Long gameId, Long versionId, String rating) { + ContentRatingDO existing = contentRatingMapper.selectByGameVersion(gameId, versionId); + if (existing == null) { + ContentRatingDO ratingDO = new ContentRatingDO(); + ratingDO.setGameId(gameId); + ratingDO.setVersionId(versionId); + ratingDO.setRating(rating); + ratingDO.setRatedBy("gate"); + contentRatingMapper.insert(ratingDO); + } else { + existing.setRating(rating); + existing.setRatedBy("gate"); + contentRatingMapper.updateById(existing); + } + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ContentRatingService.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ContentRatingService.java new file mode 100644 index 00000000..366a34cb --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ContentRatingService.java @@ -0,0 +1,23 @@ +package cn.wanxiang.game.module.compliance.service.gate; + +import cn.wanxiang.game.module.compliance.dal.dataobject.rating.ContentRatingDO; + +/** + * 内容分级读 Service 接口(/app-api/compliance/rating/get 读路径) + * + * 分级写入仅由 {@link ComplianceGateService#evaluate} 落定 + admin 复核产生;本接口只读。 + * + * @author 造梦AI + */ +public interface ContentRatingService { + + /** + * 按游戏+版本取内容分级(不存在抛 COMPLIANCE_RATING_NOT_FOUND) + * + * @param gameId 游戏 ID + * @param versionId 版本 ID(无版本传 0) + * @return 内容分级 DO + */ + ContentRatingDO getRating(Long gameId, Long versionId); + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ContentRatingServiceImpl.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ContentRatingServiceImpl.java new file mode 100644 index 00000000..3311f4d9 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/gate/ContentRatingServiceImpl.java @@ -0,0 +1,31 @@ +package cn.wanxiang.game.module.compliance.service.gate; + +import cn.wanxiang.game.module.compliance.dal.dataobject.rating.ContentRatingDO; +import cn.wanxiang.game.module.compliance.dal.mysql.ContentRatingMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import static cn.wanxiang.game.module.compliance.enums.ErrorCodeConstants.COMPLIANCE_RATING_NOT_FOUND; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 内容分级读 Service 实现 + * + * @author 造梦AI + */ +@Service +public class ContentRatingServiceImpl implements ContentRatingService { + + @Resource + private ContentRatingMapper contentRatingMapper; + + @Override + public ContentRatingDO getRating(Long gameId, Long versionId) { + ContentRatingDO rating = contentRatingMapper.selectByGameVersion(gameId, versionId == null ? 0L : versionId); + if (rating == null) { + throw exception(COMPLIANCE_RATING_NOT_FOUND); + } + return rating; + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/resources/db/migration/V9.0.0__create_game_compliance.sql b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/resources/db/migration/V9.0.0__create_game_compliance.sql new file mode 100644 index 00000000..696a49dd --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/resources/db/migration/V9.0.0__create_game_compliance.sql @@ -0,0 +1,84 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:compliance(game-module-compliance,Wave3 合规域「锁风门 Gate」)| owner:Wave3 compliance 子 agent +-- 文件:V9.0.0__create_game_compliance.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V9.0.1 DROP TABLE) +-- 内容:compliance 模块三张表 —— 锁风门评估台账 game_compliance_gate_result + 内容分级 game_content_rating + 封禁台账 game_user_ban +-- 职责(架构 Doc B + D2 范围铁律):只做 Gate(聚合风格/IP 原子→verdict + 内容分级 + 封禁台账); +-- 【明确不建】审核状态机表 / audit_record —— 审核状态机权威归 project(game_review_record),compliance 绝不重写。 +-- 锁风门 seam(R1):project.submitPublish 同进程调 ComplianceGateApi.evaluate → 聚合裁决落 game_compliance_gate_result + 分级落 game_content_rating +-- → 返回 verdict 供 project 据此置 admitted(block→admitted=false 不抛异常)。 +-- 封禁联动(R3 状态分支):写 game_user_ban 台账(主操作恒执行);下架仅当 project 状态==PUBLISHED 时才调 project reviewProject(3); +-- 非 PUBLISHED 只记封禁不调状态机。【本波说明】ProjectApi 暂无 reviewProject seam,下架联动标 TODO 留 Phase C 主 agent 收口,本波仅写台账。 +-- 降权出流屏蔽:P1 本波不生效(R5,不建 FeedDownweightApi 孤儿 seam)。 +-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id; +-- 状态机用 tinyint,非法流转由 Service 校验,DO 层不承载;中文列注释;关键查询建索引、禁裸 select*。 +-- 错误码段:compliance = 1-109-***-*** +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 表:game_compliance_gate_result —— 锁风门评估台账(每次 publish 评估各落一条,审计/溯源用) +-- 用途:project.publish 注入 evaluate 时落聚合裁决 + 各原子明细 JSON;admin 复核与合规溯源据此查 +-- 写口径:非幂等(每次评估各落一条审计记录,不去重);verdict 为聚合结果 max(各原子) +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_compliance_gate_result` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '锁风门评估记录 ID', + `game_id` BIGINT NOT NULL COMMENT '评估的游戏 ID(project.game_project.id)', + `version_id` BIGINT NOT NULL DEFAULT 0 COMMENT '评估的版本 ID(待发布版本;无版本时为 0)', + `verdict` VARCHAR(16) NOT NULL DEFAULT 'pass' COMMENT '聚合裁决:pass 通过/review 需人工复核/block 拦截(= max(各原子))', + `rating` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '本次评估落定的内容分级:all/8+/12+/16+(可空)', + `details` VARCHAR(2048) NOT NULL DEFAULT '' COMMENT '各合规原子明细 JSON([{atom,verdict,reason}],便于复核溯源)', + `trace_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '全链路追踪 ID(与 publish 链路对账)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;锁风门评估为 system/发布发起方)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(评估时间)', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `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`), + KEY `idx_game_version` (`game_id`, `version_id`) COMMENT '按游戏+版本查最近一次锁风门评估(复核/溯源)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '锁风门评估台账(每次 publish 评估落一条,聚合裁决+原子明细;审计/溯源)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_content_rating —— 内容分级表(按游戏+版本唯一;评估时落、admin 复核可改) +-- 用途:/app-api/compliance/rating/get 读分级;分级写入仅由 Gate evaluate 落定 + admin 复核产生 +-- 唯一键:(game_id,version_id) 业务唯一(含 deleted/tenant_id 适配逻辑删+多租户),同游戏同版本只存一条最新分级 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_content_rating` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '内容分级记录 ID', + `game_id` BIGINT NOT NULL COMMENT '游戏 ID(project.game_project.id)', + `version_id` BIGINT NOT NULL DEFAULT 0 COMMENT '版本 ID(无版本时为 0)', + `rating` VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '内容分级:all 全年龄/8+/12+/16+', + `rated_by` VARCHAR(32) NOT NULL DEFAULT 'gate' COMMENT '分级来源:gate 锁风门自动/admin 人工复核', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(admin 复核改分级时记录)', + `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_game_version` (`game_id`, `version_id`, `deleted`, `tenant_id`) COMMENT '游戏+版本分级业务唯一键(含 deleted/tenant_id 适配逻辑删+多租户)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '内容分级表(评估落定+admin 复核;/app-api 只读取)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_user_ban —— 封禁台账(封禁/降权的权威台账;主操作恒执行) +-- 用途:admin 封禁游戏/用户落台账(恒执行);R3 下架联动仅当游戏==PUBLISHED 时才调 project 状态机(本波标 TODO); +-- R5 降权出流屏蔽 P1 本波不生效(不建 feed seam) +-- 唯一键:(target_type,target_id,status) 生效中唯一(status=1 生效,含 deleted/tenant_id),同目标不可重复生效封禁;解封后(status=0)可再次封禁 +-- 状态机 status:1 生效 → 0 已解封(解封由 admin 触发,Service 校验合法流转) +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_user_ban` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '封禁台账记录 ID', + `target_type` TINYINT NOT NULL COMMENT '封禁目标类型:1 游戏 / 2 用户', + `target_id` BIGINT NOT NULL COMMENT '封禁目标 ID(游戏=game_id / 用户=user_id)', + `ban_type` TINYINT NOT NULL DEFAULT 1 COMMENT '处置类型:1 封禁(下架/禁用)/ 2 降权(出流屏蔽,P1 本波不生效)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '封禁状态机:1 生效 / 0 已解封(合法流转由 Service 校验)', + `reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封禁/降权原因(合规处置说明)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;封禁操作的 admin userId)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(封禁时间)', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(解封操作的 admin userId)', + `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_target_active` (`target_type`, `target_id`, `status`, `deleted`, `tenant_id`) COMMENT '生效中封禁业务唯一键:同目标 status=1 不可重复(含 deleted/tenant_id 适配逻辑删+多租户)', + KEY `idx_type_status` (`target_type`, `status`) COMMENT 'admin 按目标类型+状态分页查封禁台账' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '封禁台账表(封禁/降权权威台账;主操作恒执行,下架联动按 project 状态分支)'; diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImplTest.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImplTest.java new file mode 100644 index 00000000..981e41a0 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImplTest.java @@ -0,0 +1,151 @@ +package cn.wanxiang.game.module.compliance.service.ban; + +import cn.wanxiang.game.module.compliance.controller.admin.vo.BanCreateReqVO; +import cn.wanxiang.game.module.compliance.dal.dataobject.ban.UserBanDO; +import cn.wanxiang.game.module.compliance.dal.mysql.UserBanMapper; +import cn.wanxiang.game.module.compliance.enums.BanStatusEnum; +import cn.wanxiang.game.module.compliance.enums.BanTargetTypeEnum; +import cn.wanxiang.game.module.project.api.ProjectApi; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static cn.wanxiang.game.module.compliance.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link UserBanServiceImpl} 单元测试(纯 Mockito,不依赖 DB;仿 AdSlotServiceImplTest 范式) + * + * 覆盖 R3 封禁状态分支核心: + * - 封禁游戏:写台账=主操作恒执行(断言 insert 被调用);下架联动本波 TODO(断言不裸调 project-api,seam 未就绪); + * - 封禁用户:写台账(用户目标无下架联动); + * - 查重:同目标已有生效封禁 → 报 COMPLIANCE_BAN_ALREADY_EXISTS,不再插入; + * - 类型非法 → 报 COMPLIANCE_BAN_TARGET_INVALID。 + * + * 【说明 R3/openIssue】ProjectApi 本波无 reviewProject/状态查询 seam,下架联动留 Phase C; + * 故本测试断言「封禁恒写台账 + 不调用 project-api」= 当前 seam 边界下 R3 状态分支的诚实形态(不伪造未就绪的下架调用)。 + * + * @author 造梦AI + */ +class UserBanServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private UserBanServiceImpl userBanService; + + @Mock + private UserBanMapper userBanMapper; + @Mock + private ProjectApi projectApi; + + // ============================== createBan ============================== + + @Test + void testCreateBan_game_writesLedgerAndNoUnlistCallThisWave() { + // 准备:游戏目标,无已生效封禁;insert 回填自增 ID + when(userBanMapper.selectActiveByTarget(BanTargetTypeEnum.GAME.getType(), 1024L)).thenReturn(null); + doAnswer(inv -> { + UserBanDO d = inv.getArgument(0); + d.setId(1L); + return 1; + }).when(userBanMapper).insert(any(UserBanDO.class)); + + BanCreateReqVO reqVO = mockBan(BanTargetTypeEnum.GAME.getType(), 1024L, "违规内容"); + Long id = userBanService.createBan(reqVO, 1L); + + // 断言:台账写入=主操作恒执行(status=生效) + assertEquals(1L, id); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBanDO.class); + verify(userBanMapper).insert(captor.capture()); + UserBanDO saved = captor.getValue(); + assertEquals(BanTargetTypeEnum.GAME.getType(), saved.getTargetType()); + assertEquals(1024L, saved.getTargetId()); + assertEquals(BanStatusEnum.ACTIVE.getStatus(), saved.getStatus()); + assertEquals(1, saved.getBanType()); // 缺省封禁 + // 断言:本波下架联动 TODO——不裸调 project-api(reviewProject seam 未就绪,留 Phase C 收口) + verifyNoInteractions(projectApi); + } + + @Test + void testCreateBan_user_writesLedgerOnly() { + // 准备:用户目标,无已生效封禁 + when(userBanMapper.selectActiveByTarget(BanTargetTypeEnum.USER.getType(), 99L)).thenReturn(null); + doAnswer(inv -> { + UserBanDO d = inv.getArgument(0); + d.setId(2L); + return 1; + }).when(userBanMapper).insert(any(UserBanDO.class)); + + BanCreateReqVO reqVO = mockBan(BanTargetTypeEnum.USER.getType(), 99L, "封号"); + Long id = userBanService.createBan(reqVO, 1L); + + // 断言:用户目标只写台账、无任何 project 联动 + assertEquals(2L, id); + verify(userBanMapper).insert(any(UserBanDO.class)); + verifyNoInteractions(projectApi); + } + + @Test + void testCreateBan_alreadyExists() { + // 同目标已有生效封禁 → 报已封禁,不再插入 + when(userBanMapper.selectActiveByTarget(BanTargetTypeEnum.GAME.getType(), 1024L)).thenReturn(new UserBanDO()); + BanCreateReqVO reqVO = mockBan(BanTargetTypeEnum.GAME.getType(), 1024L, "重复封禁"); + + ServiceException ex = assertThrows(ServiceException.class, () -> userBanService.createBan(reqVO, 1L)); + assertEquals(COMPLIANCE_BAN_ALREADY_EXISTS.getCode(), ex.getCode()); + verify(userBanMapper, never()).insert(any(UserBanDO.class)); // 类型消歧 + } + + @Test + void testCreateBan_targetTypeInvalid() { + // 非法目标类型(如 3)→ 报类型非法 + BanCreateReqVO reqVO = mockBan(3, 1024L, "非法类型"); + + ServiceException ex = assertThrows(ServiceException.class, () -> userBanService.createBan(reqVO, 1L)); + assertEquals(COMPLIANCE_BAN_TARGET_INVALID.getCode(), ex.getCode()); + verify(userBanMapper, never()).insert(any(UserBanDO.class)); + } + + // ============================== cancelBan ============================== + + @Test + void testCancelBan_success() { + // 准备:生效中封禁记录 + UserBanDO active = new UserBanDO(); + active.setId(1L); + active.setStatus(BanStatusEnum.ACTIVE.getStatus()); + when(userBanMapper.selectById(1L)).thenReturn(active); + + userBanService.cancelBan(1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBanDO.class); + verify(userBanMapper).updateById(captor.capture()); + assertEquals(BanStatusEnum.CANCELLED.getStatus(), captor.getValue().getStatus()); + } + + @Test + void testCancelBan_notExists() { + // 记录不存在 → 报不存在 + when(userBanMapper.selectById(9L)).thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, () -> userBanService.cancelBan(9L)); + assertEquals(COMPLIANCE_BAN_NOT_EXISTS.getCode(), ex.getCode()); + verify(userBanMapper, never()).updateById(any(UserBanDO.class)); + } + + // ============================== 测试夹具 ============================== + + /** 构造封禁入参 */ + private static BanCreateReqVO mockBan(Integer targetType, Long targetId, String reason) { + BanCreateReqVO reqVO = new BanCreateReqVO(); + reqVO.setTargetType(targetType); + reqVO.setTargetId(targetId); + reqVO.setReason(reason); + return reqVO; + } + +} diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateServiceImplTest.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateServiceImplTest.java new file mode 100644 index 00000000..7e478f46 --- /dev/null +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/gate/ComplianceGateServiceImplTest.java @@ -0,0 +1,116 @@ +package cn.wanxiang.game.module.compliance.service.gate; + +import cn.wanxiang.game.module.compliance.dal.dataobject.gate.GateResultDO; +import cn.wanxiang.game.module.compliance.dal.dataobject.rating.ContentRatingDO; +import cn.wanxiang.game.module.compliance.dal.mysql.ContentRatingMapper; +import cn.wanxiang.game.module.compliance.dal.mysql.GateResultMapper; +import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO; +import cn.wanxiang.game.module.compliance.dto.GateVerdict; +import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum; +import cn.wanxiang.game.module.compliance.framework.atom.ComplianceAtomResult; +import cn.wanxiang.game.module.compliance.framework.atom.IpComplianceSeam; +import cn.wanxiang.game.module.compliance.framework.atom.StyleComplianceAtom; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link ComplianceGateServiceImpl} 单元测试(纯 Mockito,不依赖 DB;仿 AdSlotServiceImplTest 范式) + * + * 覆盖锁风门聚合核心: + * - 全原子 pass → 聚合 verdict=pass,落台账 + 落分级; + * - 任一原子 block → 聚合 verdict=block(block>review>pass),不抛异常仍正常返回(供 project 置 admitted=false)。 + * mock BaseMapper.insert/updateById 用 any(DO.class) 消歧(裸 any() 会因重载报错)。 + * + * @author 造梦AI + */ +class ComplianceGateServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ComplianceGateServiceImpl complianceGateService; + + @Mock + private StyleComplianceAtom styleComplianceAtom; + @Mock + private IpComplianceSeam ipComplianceSeam; + @Mock + private GateResultMapper gateResultMapper; + @Mock + private ContentRatingMapper contentRatingMapper; + + // ============================== evaluate ============================== + + @Test + void testEvaluate_pass() { + // 准备:两原子均 pass;分级记录不存在(走 insert 分支) + when(styleComplianceAtom.evaluate(any(ComplianceGateReqDTO.class))) + .thenReturn(ComplianceAtomResult.pass(StyleComplianceAtom.ATOM)); + when(ipComplianceSeam.evaluate(any(ComplianceGateReqDTO.class))) + .thenReturn(ComplianceAtomResult.pass(IpComplianceSeam.ATOM)); + when(contentRatingMapper.selectByGameVersion(1024L, 7L)).thenReturn(null); + + ComplianceGateReqDTO req = mockReq(1024L, 7L, "all"); + GateVerdict verdict = complianceGateService.evaluate(req); + + // 断言:聚合 pass;落台账(verdict=pass);落分级(insert) + assertEquals(ComplianceVerdictEnum.PASS.getVerdict(), verdict.getVerdict()); + assertEquals("all", verdict.getRating()); + assertEquals(2, verdict.getDetail().size()); + + ArgumentCaptor gateCaptor = ArgumentCaptor.forClass(GateResultDO.class); + verify(gateResultMapper).insert(gateCaptor.capture()); + assertEquals(ComplianceVerdictEnum.PASS.getVerdict(), gateCaptor.getValue().getVerdict()); + assertEquals(1024L, gateCaptor.getValue().getGameId()); + assertEquals(7L, gateCaptor.getValue().getVersionId()); + verify(contentRatingMapper).insert(any(ContentRatingDO.class)); // 分级不存在 → insert + verify(contentRatingMapper, never()).updateById(any(ContentRatingDO.class)); + } + + @Test + void testEvaluate_block() { + // 准备:风格原子 block,IP 原子 pass → 聚合应为 block(block>review>pass);分级已存在(走 update 分支) + when(styleComplianceAtom.evaluate(any(ComplianceGateReqDTO.class))) + .thenReturn(new ComplianceAtomResult(StyleComplianceAtom.ATOM, ComplianceVerdictEnum.BLOCK, "命中违禁词")); + when(ipComplianceSeam.evaluate(any(ComplianceGateReqDTO.class))) + .thenReturn(ComplianceAtomResult.pass(IpComplianceSeam.ATOM)); + ContentRatingDO existing = new ContentRatingDO(); + existing.setId(5L); + existing.setGameId(1024L); + existing.setVersionId(7L); + when(contentRatingMapper.selectByGameVersion(1024L, 7L)).thenReturn(existing); + + ComplianceGateReqDTO req = mockReq(1024L, 7L, "12+"); + // block 不抛异常,正常返回 verdict + GateVerdict verdict = assertDoesNotThrow(() -> complianceGateService.evaluate(req)); + + // 断言:聚合 block;台账记 block;分级走 update(已存在) + assertEquals(ComplianceVerdictEnum.BLOCK.getVerdict(), verdict.getVerdict()); + assertEquals("12+", verdict.getRating()); + ArgumentCaptor gateCaptor = ArgumentCaptor.forClass(GateResultDO.class); + verify(gateResultMapper).insert(gateCaptor.capture()); + assertEquals(ComplianceVerdictEnum.BLOCK.getVerdict(), gateCaptor.getValue().getVerdict()); + verify(contentRatingMapper).updateById(any(ContentRatingDO.class)); // 分级存在 → update + verify(contentRatingMapper, never()).insert(any(ContentRatingDO.class)); + } + + // ============================== 测试夹具 ============================== + + /** 构造锁风门评估入参 */ + private static ComplianceGateReqDTO mockReq(Long gameId, Long versionId, String ageRating) { + ComplianceGateReqDTO req = new ComplianceGateReqDTO(); + req.setGameId(gameId); + req.setVersionId(versionId); + req.setTitle("测试游戏"); + req.setSummary("测试简介"); + req.setAgeRating(ageRating); + req.setPromptHash("trace-test-001"); + return req; + } + +} diff --git a/game-cloud/game-module-compliance/pom.xml b/game-cloud/game-module-compliance/pom.xml new file mode 100644 index 00000000..75bbbed4 --- /dev/null +++ b/game-cloud/game-module-compliance/pom.xml @@ -0,0 +1,27 @@ + + + + + cn.iocoder.cloud + yudao + ${revision} + + 4.0.0 + + + game-module-compliance-api + game-module-compliance-server + + game-module-compliance + pom + + ${project.artifactId} + + 造梦AI - compliance 模块(合规锁风门 Gate):发布前合规扫描(聚合风格/IP 原子→verdict)+ 内容分级 + 封禁台账。 + D2 范围铁律:只做 Gate,绝不重写 project 审核状态机(审核状态机权威归 project);只交付 ComplianceGateApi.evaluate seam 供 project.publish 注入。 + 错误码段 = 1-109-***-***。Flyway = V9。 + + + diff --git a/game-cloud/game-module-feed/game-module-feed-server/pom.xml b/game-cloud/game-module-feed/game-module-feed-server/pom.xml index f15e6f24..31253cbf 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/pom.xml +++ b/game-cloud/game-module-feed/game-module-feed-server/pom.xml @@ -31,6 +31,13 @@ ${revision} + + + cn.iocoder.cloud + game-module-project-api + ${revision} + + cn.iocoder.cloud diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/controller/app/feed/vo/ShareMetaRespVO.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/controller/app/feed/vo/ShareMetaRespVO.java index 454b754d..b588911d 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/controller/app/feed/vo/ShareMetaRespVO.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/controller/app/feed/vo/ShareMetaRespVO.java @@ -33,4 +33,7 @@ public class ShareMetaRespVO { @Schema(description = "回显的渠道标识(渠道归因 T-FED-10)", example = "wechat") private String channel; + @Schema(description = "当前生效版本 ID(GAP-3,与 FeedItem.versionId 同源 project.currentVersionId;仅已发布游戏回填,否则为空)", example = "2048") + private Long versionId; + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java index 7881a02a..45b4926e 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java @@ -15,6 +15,7 @@ import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO; import cn.wanxiang.game.module.feed.dal.mysql.interact.FeedInteractLogMapper; import cn.wanxiang.game.module.feed.dal.mysql.rank.FeedRankMapper; import cn.wanxiang.game.module.feed.enums.FeedActionEnum; +import cn.wanxiang.game.module.project.api.ProjectApi; import cn.iocoder.yudao.framework.common.pojo.PageResult; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -51,6 +52,15 @@ public class FeedServiceImpl implements FeedService { @Resource private FeedInteractLogMapper feedInteractLogMapper; + /** + * project 模块 RPC 契约(仅依赖 -api) + * + * GAP-3:分享元数据回填当前生效版本 ID(getCurrentVersionId),供分享落地页直达即玩加载。 + * MVP 单体:由 project 模块 ProjectApiImpl(@RestController @Primary) 就地解析。 + */ + @Resource + private ProjectApi projectApi; + // ============================== 游戏流 ============================== @Override @@ -154,7 +164,8 @@ public class FeedServiceImpl implements FeedService { @Override public ShareMetaRespVO getShareMeta(Long gameId, String channel) { // 发布门禁:仅 status=4 已发布可分享 - if (!isGamePublished(gameId)) { + boolean published = isGamePublished(gameId); + if (!published) { throw exception(FEED_SHARE_GAME_NOT_PUBLISHED); } ShareMetaRespVO meta = new ShareMetaRespVO(); @@ -166,9 +177,31 @@ public class FeedServiceImpl implements FeedService { meta.setOgTitle(""); meta.setOgImage(""); meta.setOgDescription(""); + // GAP-3 当前生效版本回填:仅已发布游戏回填 versionId(与 FeedItem.versionId 同源 project.currentVersionId), + // 供分享落地页直达即玩加载。getCurrentVersionId 对「不存在/无生效版本」返回 null(code=0 + data=null, + // getCheckedData() 不抛异常),此处随之留空,不阻断分享。 + meta.setVersionId(resolveCurrentVersionId(gameId, published)); return meta; } + /** + * 解析当前生效版本 ID(GAP-3 回填,仅已发布游戏回填) + * + * 仅当游戏已发布时调 project -api 取当前生效版本;未发布或 project 侧无生效版本(返回 null)时留空,由前端做缺省兜底。 + * 提取为独立方法便于单测桩控正反两路(已发布有版本 / 已发布无版本)。 + * + * @param gameId 游戏 ID + * @param published 是否已发布(getShareMeta 已门禁,此处恒 true;保留入参以表达「仅已发布回填」语义) + * @return 当前生效版本 ID;未发布或无生效版本返回 null + */ + private Long resolveCurrentVersionId(Long gameId, boolean published) { + if (!published) { + return null; + } + // getCheckedData():code=0 时返回 data(可能为 null,project 侧约定无生效版本即 null),非 0 抛异常 + return projectApi.getCurrentVersionId(gameId).getCheckedData(); + } + // ============================== 精选池运营 ============================== @Override diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java index ea820ae1..590ed4a6 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java @@ -10,7 +10,9 @@ import cn.wanxiang.game.module.feed.dal.dataobject.interact.FeedInteractLogDO; import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO; import cn.wanxiang.game.module.feed.dal.mysql.interact.FeedInteractLogMapper; import cn.wanxiang.game.module.feed.dal.mysql.rank.FeedRankMapper; +import cn.wanxiang.game.module.project.api.ProjectApi; import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -46,6 +48,8 @@ class FeedServiceImplTest extends BaseMockitoUnitTest { private FeedRankMapper feedRankMapper; @Mock private FeedInteractLogMapper feedInteractLogMapper; + @Mock + private ProjectApi projectApi; // GAP-3:分享回填 currentVersionId 的 project 契约 // ============================== 游戏流 ============================== @@ -144,20 +148,38 @@ class FeedServiceImplTest extends BaseMockitoUnitTest { @Test void testGetShareMeta_notPublishedRejected() { - // 桩控发布门禁失败 + // 桩控发布门禁失败 —— 未发布路:直接抛门禁异常,根本不进入 versionId 回填(projectApi 不被调用) doReturn(false).when(feedService).isGamePublished(1L); ServiceException ex = assertThrows(ServiceException.class, () -> feedService.getShareMeta(1L, "wechat")); assertEquals(FEED_SHARE_GAME_NOT_PUBLISHED.getCode(), ex.getCode()); + verify(projectApi, never()).getCurrentVersionId(any()); // 未发布不回填 versionId } @Test - void testGetShareMeta_channelInShareUrl() { + void testGetShareMeta_channelInShareUrl_withVersionId() { + // 已发布有生效版本路:回填 versionId(与 FeedItem.versionId 同源) doReturn(true).when(feedService).isGamePublished(1L); + when(projectApi.getCurrentVersionId(1L)).thenReturn(CommonResult.success(2048L)); + ShareMetaRespVO meta = feedService.getShareMeta(1L, "wechat"); + assertEquals(1L, meta.getGameId()); assertEquals("wechat", meta.getChannel()); assertTrue(meta.getShareUrl().contains("utm_source=wechat")); // 渠道参数写入链接 + assertEquals(2048L, meta.getVersionId()); // 已发布回填当前生效版本 + } + + @Test + void testGetShareMeta_publishedButNoCurrentVersion_versionIdNull() { + // 已发布但 project 侧无生效版本路:getCurrentVersionId 返回 code=0+data=null → versionId 留空,不阻断分享 + doReturn(true).when(feedService).isGamePublished(1L); + when(projectApi.getCurrentVersionId(1L)).thenReturn(CommonResult.success(null)); + + ShareMetaRespVO meta = feedService.getShareMeta(1L, "wechat"); + + assertEquals(1L, meta.getGameId()); + assertNull(meta.getVersionId()); // 无生效版本则留空(前端缺省兜底) } // ============================== 精选池:发布门禁 + upsert ============================== diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java index c27ead8d..869f2b44 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java @@ -26,6 +26,10 @@ public class RuntimePackageRespVO { @Schema(description = "GamePackage(manifest)OSS/CDN URL(按 /games/{gameId}/versions/{versionId}/ 版本化)") private String packageUrl; + @Schema(description = "manifest.json 取包清单 URL(GAP-2 分支A:由 packageUrl 同前缀派生 .../manifest.json,宿主先 fetch 校验 sha256 再注入)", + example = "https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json") + private String manifestUrl; + @Schema(description = "入口文件相对路径(#4 manifest.entry)", example = "index.html") private String entry; diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvert.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvert.java index ce9bb467..764056e3 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvert.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvert.java @@ -41,6 +41,9 @@ public class RuntimeConvert { } // 平铺字段(gameId/versionId/templateId/packageUrl/entry/runtimeVersion/preloadPolicy/bundleSize/checksum 同名直拷) RuntimePackageRespVO vo = BeanUtils.toBean(pkg, RuntimePackageRespVO.class); + // GAP-2 分支A(纯派生,零 DDL):manifestUrl 由 packageUrl 同前缀派生 .../manifest.json, + // 宿主据此先 fetch manifest 并按 checksum 做 sha256 完整性校验,通过后再注入运行容器(T-RT-15)。 + vo.setManifestUrl(deriveManifestUrl(pkg.getPackageUrl())); // 沙箱策略单独组装:sandboxAttr 直拷,allowOrigins 逗号串拆 List SandboxPolicyVO sandbox = new SandboxPolicyVO(); sandbox.setSandboxAttr(pkg.getSandboxAttr()); @@ -49,6 +52,30 @@ public class RuntimeConvert { return vo; } + /** + * 由 packageUrl 同前缀派生 manifest.json 取包清单 URL(GAP-2 分支A,纯派生不增列) + * + * 规则:取 packageUrl 末段 '/' 之前的同级目录前缀,拼接 manifest.json。 + * - 以 '/' 结尾(目录形态,如 .../versions/2048/):直接追加 manifest.json; + * - 含文件名(如 .../2048/package.zip):替换末段文件名为 manifest.json,保持同前缀同级; + * - 空/空白:返回 null(无包则无清单,宿主走兜底,不构造非法 URL)。 + * + * @param packageUrl 运行包 OSS/CDN URL(按 /games/{gameId}/versions/{versionId}/ 版本化) + * @return manifest.json 清单 URL;packageUrl 空时返回 null + */ + public static String deriveManifestUrl(String packageUrl) { + if (!StringUtils.hasText(packageUrl)) { + return null; + } + int lastSlash = packageUrl.lastIndexOf('/'); + // 无 '/'(异常退化形态):直接当作前缀目录,拼接 manifest.json,避免越权改写其它路径 + if (lastSlash < 0) { + return packageUrl + "/manifest.json"; + } + // 保留含末位 '/' 的同级前缀,替换末段文件名(或空段)为 manifest.json + return packageUrl.substring(0, lastSlash + 1) + "manifest.json"; + } + // ============================== 编译任务 ============================== /** diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvertTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvertTest.java new file mode 100644 index 00000000..1941cb4b --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvertTest.java @@ -0,0 +1,77 @@ +package cn.wanxiang.game.module.runtime.convert.runtime; + +import cn.wanxiang.game.module.runtime.controller.app.runtime.vo.RuntimePackageRespVO; +import cn.wanxiang.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link RuntimeConvert} 单元测试(纯静态方法,不依赖 Spring/DB) + * + * 覆盖 GAP-2 分支A:manifestUrl 由 packageUrl 同前缀派生 .../manifest.json(含目录形态/文件名形态/空值/退化形态各路)。 + * + * @author 造梦AI + */ +class RuntimeConvertTest { + + // ============================== deriveManifestUrl 同前缀派生 ============================== + + @Test + void testDeriveManifestUrl_dirForm() { + // packageUrl 以 '/' 结尾(版本化目录形态)→ 直接追加 manifest.json + assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json", + RuntimeConvert.deriveManifestUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/")); + } + + @Test + void testDeriveManifestUrl_fileForm() { + // packageUrl 含末段文件名 → 替换为同级 manifest.json,保持同前缀 + assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json", + RuntimeConvert.deriveManifestUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip")); + } + + @Test + void testDeriveManifestUrl_noSlashDegraded() { + // 无 '/' 退化形态 → 当作前缀目录拼接,不越权改写其它路径 + assertEquals("pkg/manifest.json", RuntimeConvert.deriveManifestUrl("pkg")); + } + + @Test + void testDeriveManifestUrl_blankReturnsNull() { + // 空/空白 packageUrl → 返回 null(无包则无清单,不构造非法 URL) + assertNull(RuntimeConvert.deriveManifestUrl(null)); + assertNull(RuntimeConvert.deriveManifestUrl("")); + assertNull(RuntimeConvert.deriveManifestUrl(" ")); + } + + // ============================== toPackageRespVO 整体回填 ============================== + + @Test + void testToPackageRespVO_manifestUrlDerivedAndSandboxAssembled() { + RuntimePackageDO pkg = new RuntimePackageDO(); + pkg.setGameId(1024L); + pkg.setVersionId(2048L); + pkg.setPackageUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip"); + pkg.setSandboxAttr("allow-scripts"); + pkg.setAllowOrigins("https://a.com,https://b.com"); + + RuntimePackageRespVO vo = RuntimeConvert.toPackageRespVO(pkg); + + assertNotNull(vo); + // 平铺字段直拷 + manifestUrl 同前缀派生 + assertEquals(1024L, vo.getGameId()); + assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip", vo.getPackageUrl()); + assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json", vo.getManifestUrl()); + // 沙箱策略 allowOrigins 字符串拆 List + assertNotNull(vo.getSandbox()); + assertEquals("allow-scripts", vo.getSandbox().getSandboxAttr()); + assertEquals(2, vo.getSandbox().getAllowOrigins().size()); + } + + @Test + void testToPackageRespVO_nullReturnsNull() { + assertNull(RuntimeConvert.toPackageRespVO(null)); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-api/pom.xml b/game-cloud/game-module-studio/game-module-studio-api/pom.xml new file mode 100644 index 00000000..e4ef3b0d --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/pom.xml @@ -0,0 +1,49 @@ + + + + cn.iocoder.cloud + game-module-studio + ${revision} + + 4.0.0 + game-module-studio-api + jar + + ${project.artifactId} + + studio 模块 API:错误码常量、创作会话状态机枚举。 + MVP 创作主链路 studio 为 /app-api 编排入口(不对外暴露跨模块 Feign 契约),本层仅定枚举与错误码段。 + + + + + + cn.iocoder.cloud + yudao-common + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + provided + + + + + org.springframework.boot + spring-boot-starter-validation + true + + + + + org.springframework.cloud + spring-cloud-starter-openfeign + true + + + + diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/ApiConstants.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/ApiConstants.java new file mode 100644 index 00000000..5cf0124c --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/ApiConstants.java @@ -0,0 +1,32 @@ +package cn.wanxiang.game.module.studio.enums; + +import cn.iocoder.yudao.framework.common.enums.RpcConstants; + +/** + * studio 模块 API 相关枚举(Feign 服务名 + RPC 前缀) + * + * 仿 yudao system / project / ad 的 {@code ApiConstants} 风格。MVP 单体下 studio 仅作 /app-api 创作编排入口, + * 暂不对外暴露跨模块 Feign 契约(studio 是调用方:调 project-api / aigc-api),本类保留以备拆微服务与统一前缀。 + * - NAME:Feign 目标服务名。MVP 为单体(spring.application.name=yudao-server,所有模块编入一个 JAR)。 + * - PREFIX:RPC 接口前缀 /rpc-api/studio,与业务端 /app-api 隔离(预留)。 + * + * @author 造梦AI + */ +public class ApiConstants { + + /** + * 服务名(拆微服务后需与 studio 服务的 spring.application.name 一致;MVP 单体下仅作占位) + */ + public static final String NAME = "studio-server"; + + /** + * RPC API 前缀(/rpc-api/studio,预留) + */ + public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/studio"; + + /** + * 接口版本 + */ + public static final String VERSION = "1.0.0"; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/ErrorCodeConstants.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/ErrorCodeConstants.java new file mode 100644 index 00000000..b430bdb3 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/ErrorCodeConstants.java @@ -0,0 +1,34 @@ +package cn.wanxiang.game.module.studio.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * studio 模块错误码枚举类 + * + * studio 模块独占 1-112-***-*** 段(R4 治理新登记,见 contracts/api-schemas/studio.yaml 头部 + contracts/README §4)。 + * 约定:禁止与其它模块错误码段重叠(100 project/101 aigc/102 runtime/103 feed/104 telemetry/105 pay/106 trade/ + * 107 community/108 ip/109 compliance/110 biz/111 ad/112 studio);新增错误码在此登记。 + * + * @author 造梦AI + */ +public interface ErrorCodeConstants { + + // ========== 创作会话 1-112-000-*** ========== + /** 创作会话不存在(按主键 id 查不到) */ + ErrorCode STUDIO_SESSION_NOT_EXISTS = new ErrorCode(1_112_000_000, "创作会话不存在"); + /** 创作会话归属校验失败(创作者只能操作自己的会话,服务端强校验不依赖前端) */ + ErrorCode STUDIO_SESSION_NOT_OWNER = new ErrorCode(1_112_000_001, "无权操作他人的创作会话"); + /** 建草稿/生成:玩法模板不能为空 */ + ErrorCode STUDIO_TEMPLATE_REQUIRED = new ErrorCode(1_112_000_002, "玩法模板不能为空"); + + // ========== 创作任务链 1-112-001-*** ========== + /** 创作任务链不存在(按主键 id 查不到) */ + ErrorCode STUDIO_TASKCHAIN_NOT_EXISTS = new ErrorCode(1_112_001_000, "创作任务链不存在"); + + // ========== 生成委托 1-112-002-*** ========== + /** 生成委托失败(aigc 调用异常:超时/返回错误码/产物缺失) */ + ErrorCode STUDIO_AIGC_DELEGATE_FAIL = new ErrorCode(1_112_002_000, "生成委托失败(aigc 调用异常)"); + /** 草稿移交建游戏失败(project createProject 调用异常或返回空 gameId) */ + ErrorCode STUDIO_PROJECT_CREATE_FAIL = new ErrorCode(1_112_002_001, "草稿移交建游戏失败(project 调用异常)"); + +} diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/StudioSessionStatusEnum.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/StudioSessionStatusEnum.java new file mode 100644 index 00000000..bd72ed68 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/cn/wanxiang/game/module/studio/enums/StudioSessionStatusEnum.java @@ -0,0 +1,44 @@ +package cn.wanxiang.game.module.studio.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Objects; + +/** + * 创作会话状态机枚举(对齐契约 studio.yaml + db V8.0.0 game_studio_session.status) + * + * MVP 最小版:草稿 → 生成中 → 完成/失败(线性单步任务链 DRAFT→GENERATING→DONE/FAILED,对齐 P-CRT-01/09)。 + * 状态来源:DRAFT 由 createDraft 写;GENERATING 由 generate 写;DONE/FAILED 由 getTask 轮询透传 aigc 终态映射。 + * 注意:状态机合法性由 StudioService 校验,DO 层只承载数据。 + * + * @author 造梦AI + */ +@Getter +@AllArgsConstructor +public enum StudioSessionStatusEnum { + + DRAFT(0, "草稿"), + GENERATING(1, "生成中"), + DONE(2, "完成"), + FAILED(3, "失败"); + + /** 状态值(落库 tinyint) */ + private final Integer status; + /** 状态名(展示用) */ + private final String name; + + /** + * 根据状态值查枚举 + * + * @param status 状态值 + * @return 枚举;非法值返回 null + */ + public static StudioSessionStatusEnum of(Integer status) { + return Arrays.stream(values()) + .filter(e -> Objects.equals(e.status, status)) + .findFirst().orElse(null); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/pom.xml b/game-cloud/game-module-studio/game-module-studio-server/pom.xml new file mode 100644 index 00000000..eb2e127a --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/pom.xml @@ -0,0 +1,95 @@ + + + + cn.iocoder.cloud + game-module-studio + ${revision} + + 4.0.0 + game-module-studio-server + jar + + ${project.artifactId} + + studio 模块业务实现:controller(app) / service / dal(DO·Mapper) / convert。 + 编排调 project-api(createProject 建草稿) + aigc-api(submitGenerate/getTask/retryTask)。 + 被 yudao-server 聚合启动(随单体一起加载,无需独立 bootstrap)。 + + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-env + + + + + cn.iocoder.cloud + game-module-studio-api + ${revision} + + + + + cn.iocoder.cloud + game-module-project-api + ${revision} + + + + + cn.iocoder.cloud + game-module-aigc-api + ${revision} + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-biz-data-permission + + + cn.iocoder.cloud + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-security + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-mybatis + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-redis + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-rpc + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-test + test + + + + + ${project.artifactId} + + + diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/AppStudioController.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/AppStudioController.java new file mode 100644 index 00000000..8af76fae --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/AppStudioController.java @@ -0,0 +1,84 @@ +package cn.wanxiang.game.module.studio.controller.app.studio; + +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioDraftCreateReqVO; +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioGenerateReqVO; +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioSessionRespVO; +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioTaskChainRespVO; +import cn.wanxiang.game.module.studio.convert.studio.StudioConvert; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO; +import cn.wanxiang.game.module.studio.service.studio.StudioService; +import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +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 org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 产品端(game-studio)- 创作工作台控制器(创作主链路编排入口) + * + * 端前缀 /app-api 由框架按包名 controller.app.* 自动添加(见 WebProperties)。 + * 鉴权:所有端点需用户 Token;创作者只能操作自己的会话/任务链,归属校验在 Service 强制(不依赖前端)。 + * 编排链路(MVP 轮询非 MQ,D4):模板浏览 → 建草稿(委托 project) → 一句话生成(委托 aigc) → 进度轮询 → 重生成。 + * + * @author 造梦AI + */ +@Tag(name = "用户 App - 创作工作台") +@RestController +@RequestMapping("/studio") +@Validated +public class AppStudioController { + + @Resource + private StudioService studioService; + + @GetMapping("/template/list") + @Operation(summary = "玩法模板列表", description = "模板浏览/应用(P-TPL-01/03),透传 aigc 模板注册表") + public CommonResult> getTemplateList() { + return success(studioService.getTemplateList()); + } + + @PostMapping("/draft") + @Operation(summary = "建草稿", description = "进工作坊(P-CRT-10)+ 草稿移交 project(P-TPL-03),调 createProject 拿 gameId 回写会话") + public CommonResult createDraft(@Valid @RequestBody StudioDraftCreateReqVO reqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + StudioSessionDO session = studioService.createDraft(reqVO, userId); + return success(StudioConvert.toSessionRespVO(session)); + } + + @PostMapping("/generate") + @Operation(summary = "一句话生成", description = "委托 aigc 提交生成(P-CRT-01),立即返回任务链句柄,前端据 taskChainId 轮询") + public CommonResult generate(@Valid @RequestBody StudioGenerateReqVO reqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + StudioTaskChainDO chain = studioService.generate(reqVO, userId); + return success(StudioConvert.toTaskChainRespVO(chain)); + } + + @GetMapping("/task/{id}") + @Operation(summary = "进度轮询", description = "透传 aigc 进度/状态(P-CRT-12);终态映射链态,完成回填 versionId") + @Parameter(name = "id", description = "创作任务链 ID(taskChainId)", required = true, example = "1024") + public CommonResult getTask(@PathVariable("id") Long id) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + StudioTaskChainDO chain = studioService.getTaskChain(id, userId); + return success(StudioConvert.toTaskChainRespVO(chain)); + } + + @PostMapping("/task/{id}/regenerate") + @Operation(summary = "重生成迭代", description = "委托 aigc 重试(P-CRT-09),新建 retry_of 任务链记录血缘") + @Parameter(name = "id", description = "原创作任务链 ID", required = true, example = "1024") + public CommonResult regenerate(@PathVariable("id") Long id) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + StudioTaskChainDO chain = studioService.regenerate(id, userId); + return success(StudioConvert.toTaskChainRespVO(chain)); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioDraftCreateReqVO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioDraftCreateReqVO.java new file mode 100644 index 00000000..77f61c0f --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioDraftCreateReqVO.java @@ -0,0 +1,36 @@ +package cn.wanxiang.game.module.studio.controller.app.studio.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 建草稿 Request VO(对齐契约 studio.yaml StudioDraftCreateReqVO;P-CRT-10 进工作坊 + P-TPL-03 草稿移交 project) + * + * 编排:服务端调 ProjectApi.createProject(creatorUserId, title, templateId) 建游戏拿 gameId 回写会话,再 upsert 会话(status=0)。 + * attachments:JSON 字符串持久化(P-CRT-04);本波仅持久化不透传 aigc(R7)。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 建草稿 Request VO") +@Data +public class StudioDraftCreateReqVO { + + @Schema(description = "作品标题(透传 project createProject)", requiredMode = Schema.RequiredMode.REQUIRED, example = "我的放置小镇") + @NotBlank(message = "作品标题不能为空") + @Size(max = 128, message = "标题不能超过 128 个字符") + private String title; + + @Schema(description = "玩法模板 ID(透传 project/aigc)", requiredMode = Schema.RequiredMode.REQUIRED, example = "idle") + @NotBlank(message = "玩法模板不能为空") + private String templateId; + + @Schema(description = "一句话自然语言 Prompt(建草稿时可空,generate 时必填)", example = "做一个躲避陨石的小游戏") + @Size(max = 1000, message = "Prompt 不能超过 1000 个字符") + private String prompt; + + @Schema(description = "附件上下文 JSON(P-CRT-04);本波仅持久化,P1 透传 aigc") + private String attachments; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioGenerateReqVO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioGenerateReqVO.java new file mode 100644 index 00000000..245235dc --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioGenerateReqVO.java @@ -0,0 +1,33 @@ +package cn.wanxiang.game.module.studio.controller.app.studio.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 一句话生成 Request VO(对齐契约 studio.yaml StudioGenerateReqVO;P-CRT-01 一句话生成) + * + * 编排:校验 session 归属 → 落任务链(status=1) → 委托 AigcApi.submitGenerate(prompt/templateId/gameId/userId) → 回填 aigc_task_id。 + * 同步直写无 MQ(D4);attachments 仅持久化(R7),不入 aigc reqDTO(aigc reqDTO 无 attachments 字段)。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 一句话生成 Request VO") +@Data +public class StudioGenerateReqVO { + + @Schema(description = "创作会话 ID(建草稿返回的 sessionId)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "创作会话 ID 不能为空") + private Long sessionId; + + @Schema(description = "一句话自然语言 Prompt(透传 aigc submitGenerate)", requiredMode = Schema.RequiredMode.REQUIRED, example = "做一个躲避陨石的小游戏") + @NotBlank(message = "生成 Prompt 不能为空") + @Size(max = 1000, message = "Prompt 不能超过 1000 个字符") + private String prompt; + + @Schema(description = "附件上下文 JSON(P-CRT-04);【本波仅持久化,P1 透传 aigc】——studio 侧落库持久化,本波不透传 aigc") + private String attachments; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioSessionRespVO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioSessionRespVO.java new file mode 100644 index 00000000..09c10de7 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioSessionRespVO.java @@ -0,0 +1,33 @@ +package cn.wanxiang.game.module.studio.controller.app.studio.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 创作会话响应 VO(对齐契约 studio.yaml StudioSessionRespVO;建草稿/会话详情返回) + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 创作会话 Response VO") +@Data +public class StudioSessionRespVO { + + @Schema(description = "创作会话 ID(sessionId)", example = "1024") + private Long id; + + @Schema(description = "移交后的游戏 ID(project.game_project.id)", example = "2048") + private Long gameId; + + @Schema(description = "玩法模板 ID", example = "idle") + private String templateId; + + @Schema(description = "作品标题", example = "我的放置小镇") + private String title; + + @Schema(description = "一句话 Prompt", example = "做一个躲避陨石的小游戏") + private String prompt; + + @Schema(description = "会话状态:0草稿 1生成中 2完成 3失败", example = "0") + private Integer status; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioTaskChainRespVO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioTaskChainRespVO.java new file mode 100644 index 00000000..24111541 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/controller/app/studio/vo/StudioTaskChainRespVO.java @@ -0,0 +1,41 @@ +package cn.wanxiang.game.module.studio.controller.app.studio.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 创作任务链响应 VO(对齐契约 studio.yaml StudioTaskChainRespVO;generate/轮询/重生成返回) + * + * 进度轮询(P-CRT-12):前端据本 VO 的 status/progress 轮询直至终态;status=2 完成后 versionId 非空。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 创作任务链 Response VO") +@Data +public class StudioTaskChainRespVO { + + @Schema(description = "创作任务链 ID(taskChainId,轮询用)", example = "1024") + private Long id; + + @Schema(description = "所属创作会话 ID", example = "512") + private Long sessionId; + + @Schema(description = "委托的 aigc 生成任务 ID", example = "8888") + private Long aigcTaskId; + + @Schema(description = "关联游戏 ID(project.game_project.id)", example = "2048") + private Long gameId; + + @Schema(description = "产物版本 ID(project.game_version.id);完成后非空", example = "4096") + private Long versionId; + + @Schema(description = "任务链状态:0初始 1生成中 2完成 3失败", example = "1") + private Integer status; + + @Schema(description = "进度百分比 0-100(透传 aigc)", example = "60") + private Integer progress; + + @Schema(description = "若为重生成,指向原任务链 ID(P-CRT-09 血缘)", example = "1000") + private Long retryOf; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/convert/studio/StudioConvert.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/convert/studio/StudioConvert.java new file mode 100644 index 00000000..7536206c --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/convert/studio/StudioConvert.java @@ -0,0 +1,57 @@ +package cn.wanxiang.game.module.studio.convert.studio; + +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioSessionRespVO; +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioTaskChainRespVO; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO; + +/** + * studio DO ↔ VO 转换器(纯静态方法,仿 AdSlotConvert 范式;只做字段搬运,不写业务) + * + * @author 造梦AI + */ +public class StudioConvert { + + /** + * 会话 DO → 响应 VO + * + * @param session 会话 DO + * @return 响应 VO;入参 null 返回 null + */ + public static StudioSessionRespVO toSessionRespVO(StudioSessionDO session) { + if (session == null) { + return null; + } + StudioSessionRespVO vo = new StudioSessionRespVO(); + vo.setId(session.getId()); + vo.setGameId(session.getGameId()); + vo.setTemplateId(session.getTemplateId()); + vo.setTitle(session.getTitle()); + vo.setPrompt(session.getPrompt()); + vo.setStatus(session.getStatus()); + return vo; + } + + /** + * 任务链 DO → 响应 VO + * + * @param chain 任务链 DO + * @return 响应 VO;入参 null 返回 null + */ + public static StudioTaskChainRespVO toTaskChainRespVO(StudioTaskChainDO chain) { + if (chain == null) { + return null; + } + StudioTaskChainRespVO vo = new StudioTaskChainRespVO(); + vo.setId(chain.getId()); + vo.setSessionId(chain.getSessionId()); + vo.setAigcTaskId(chain.getAigcTaskId()); + vo.setGameId(chain.getGameId()); + vo.setVersionId(chain.getVersionId()); + vo.setStatus(chain.getStatus()); + vo.setProgress(chain.getProgress()); + vo.setRetryOf(chain.getRetryOf()); + return vo; + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioAssetDO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioAssetDO.java new file mode 100644 index 00000000..8620f6e7 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioAssetDO.java @@ -0,0 +1,44 @@ +package cn.wanxiang.game.module.studio.dal.dataobject.studio; + +import cn.iocoder.yudao.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_studio_asset,单步出整包槽位 seam) + * + * 继承 {@link TenantBaseDO}:自动携带审计/租户列。 + * 狠切(R7/草稿 scope):MVP 仅落槽位占位 seam,不实现六路并发资产生成;资产由 aigc 单步整包产出,studio 仅登记槽位。 + * + * @author 造梦AI + */ +@TableName("game_studio_asset") +@KeySequence("game_studio_asset_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略 +@Data +@EqualsAndHashCode(callSuper = true) +public class StudioAssetDO extends TenantBaseDO { + + /** + * 资产槽位 ID + */ + private Long id; + /** + * 所属创作会话 ID(= game_studio_session.id) + */ + private Long sessionId; + /** + * 资产槽位类型(如 sprite/background/audio/icon;MVP 槽位占位) + */ + private String assetType; + /** + * 资产 URL(OSS/MinIO;单步整包产出后登记) + */ + private String assetUrl; + /** + * 槽位状态:0未生成 1已生成(Service 校验,MVP 单步直填) + */ + private Integer status; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioSessionDO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioSessionDO.java new file mode 100644 index 00000000..1a24fa0b --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioSessionDO.java @@ -0,0 +1,58 @@ +package cn.wanxiang.game.module.studio.dal.dataobject.studio; + +import cn.wanxiang.game.module.studio.enums.StudioSessionStatusEnum; +import cn.iocoder.yudao.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_studio_session,有状态创作工作台主记录) + * + * 继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列。 + * 边界:项目/版本权威归 project(本类仅持 game_id 引用,移交后回写);状态机合法性由 StudioService 校验,DO 层只承载数据。 + * attachments:本波仅持久化(R7),不透传 aigc。 + * + * @author 造梦AI + */ +@TableName("game_studio_session") +@KeySequence("game_studio_session_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略 +@Data +@EqualsAndHashCode(callSuper = true) +public class StudioSessionDO extends TenantBaseDO { + + /** + * 创作会话 ID + */ + private Long id; + /** + * 创作者用户 ID(业务字段;DataPermission:创作者只见自己会话。区别于审计列 creator) + */ + private Long creatorUserId; + /** + * 移交后的游戏 ID(project.game_project.id);createDraft 调 createProject 后回写 + */ + private Long gameId; + /** + * 玩法模板 ID(透传 aigc,如 dodge/runner/clicker/puzzle/shooter) + */ + private String templateId; + /** + * 作品标题(建草稿入参,透传 project createProject) + */ + private String title; + /** + * 一句话自然语言 Prompt(透传 aigc submitGenerate) + */ + private String prompt; + /** + * 附件上下文(P-CRT-04,JSON 字符串);本波仅持久化,P1 透传 aigc + */ + private String attachments; + /** + * 会话状态机(枚举 {@link StudioSessionStatusEnum}):0草稿 1生成中 2完成 3失败 + */ + private Integer status; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioTaskChainDO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioTaskChainDO.java new file mode 100644 index 00000000..8d635831 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/dataobject/studio/StudioTaskChainDO.java @@ -0,0 +1,62 @@ +package cn.wanxiang.game.module.studio.dal.dataobject.studio; + +import cn.iocoder.yudao.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_studio_taskchain,线性单步编排) + * + * 继承 {@link TenantBaseDO}:自动携带审计/租户列。 + * 引用边界:aigc_task_id 引用 aigc.game_aigc_task.id(生成权威归 aigc);version_id 引用 project.game_version.id + * (版本权威归 project,aigc succeeded 后回填);本类只持引用 + 自身链态,不重建生成状态机。 + * 状态机:0初始 1生成中 2完成 3失败(透传 aigc 终态映射),合法性由 StudioService 校验。 + * + * @author 造梦AI + */ +@TableName("game_studio_taskchain") +@KeySequence("game_studio_taskchain_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略 +@Data +@EqualsAndHashCode(callSuper = true) +public class StudioTaskChainDO extends TenantBaseDO { + + /** + * 创作任务链 ID + */ + private Long id; + /** + * 所属创作会话 ID(= game_studio_session.id) + */ + private Long sessionId; + /** + * 创作者用户 ID(DataPermission 边界,归属校验用) + */ + private Long creatorUserId; + /** + * 委托的 aigc 生成任务 ID(= aigc.game_aigc_task.id);generate 后回填,轮询据此查 aigc + */ + private Long aigcTaskId; + /** + * 关联游戏 ID(project.game_project.id,透传 aigc 用) + */ + private Long gameId; + /** + * 产物版本 ID(project.game_version.id);aigc succeeded 后由 getTask 回填 + */ + private Long versionId; + /** + * 任务链状态机:0初始 1生成中 2完成 3失败(透传 aigc 终态映射,Service 校验流转) + */ + private Integer status; + /** + * 进度百分比 0-100(透传 aigc progress,供前端进度条 P-CRT-12) + */ + private Integer progress; + /** + * 若为重生成,指向原任务链 ID(P-CRT-09 重生成血缘) + */ + private Long retryOf; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioAssetMapper.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioAssetMapper.java new file mode 100644 index 00000000..67e5a0f0 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioAssetMapper.java @@ -0,0 +1,33 @@ +package cn.wanxiang.game.module.studio.dal.mysql.studio; + +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioAssetDO; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 创作资产槽位 Mapper + * + * 显式列查询(不裸 select*);按会话+槽位类型查命中 idx_session_type。 + * MVP 仅落槽位占位 seam(不六路并发),本 Mapper 仅提供按会话取槽位列表。 + * + * @author 造梦AI + */ +@Mapper +public interface StudioAssetMapper extends BaseMapperX { + + /** + * 按会话查资产槽位列表;命中 idx_session_type(session_id, asset_type) + * + * @param sessionId 会话 ID + * @return 资产槽位列表 + */ + default List selectListBySessionId(Long sessionId) { + return selectList(new LambdaQueryWrapperX() + .eq(StudioAssetDO::getSessionId, sessionId) + .orderByAsc(StudioAssetDO::getId)); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioSessionMapper.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioSessionMapper.java new file mode 100644 index 00000000..e596bd13 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioSessionMapper.java @@ -0,0 +1,33 @@ +package cn.wanxiang.game.module.studio.dal.mysql.studio; + +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import org.apache.ibatis.annotations.Mapper; + +/** + * 创作会话 Mapper + * + * 显式列查询(MyBatis-Plus 默认按 DO 字段映射,不裸 select*);「我的会话」分页命中 idx_creator。 + * + * @author 造梦AI + */ +@Mapper +public interface StudioSessionMapper extends BaseMapperX { + + /** + * 「我的创作会话」分页(app 端,创作者只见自己数据);命中 idx_creator(creator_user_id, id) + * + * @param pageParam 分页参数 + * @param creatorUserId 当前登录创作者 ID(DataPermission 边界) + * @return 分页结果 + */ + default PageResult selectMyPage(PageParam pageParam, Long creatorUserId) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .eq(StudioSessionDO::getCreatorUserId, creatorUserId) // 归属边界:只查自己 + .orderByDesc(StudioSessionDO::getId)); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioTaskChainMapper.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioTaskChainMapper.java new file mode 100644 index 00000000..c8f96997 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/dal/mysql/studio/StudioTaskChainMapper.java @@ -0,0 +1,42 @@ +package cn.wanxiang.game.module.studio.dal.mysql.studio; + +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 创作任务链 Mapper + * + * 显式列查询(不裸 select*);按会话查命中 idx_session,按 aigc 任务反查命中 idx_aigc。 + * + * @author 造梦AI + */ +@Mapper +public interface StudioTaskChainMapper extends BaseMapperX { + + /** + * 按会话查任务链列表(含重生成历史,按 id 倒序);命中 idx_session(session_id, id) + * + * @param sessionId 会话 ID + * @return 任务链列表 + */ + default List selectListBySessionId(Long sessionId) { + return selectList(new LambdaQueryWrapperX() + .eq(StudioTaskChainDO::getSessionId, sessionId) + .orderByDesc(StudioTaskChainDO::getId)); + } + + /** + * 按 aigc 任务 ID 反查任务链(轮询回写映射);命中 idx_aigc(aigc_task_id) + * + * @param aigcTaskId aigc 生成任务 ID + * @return 任务链 DO;不存在返回 null + */ + default StudioTaskChainDO selectByAigcTaskId(Long aigcTaskId) { + return selectOne(StudioTaskChainDO::getAigcTaskId, aigcTaskId); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioService.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioService.java new file mode 100644 index 00000000..9358cece --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioService.java @@ -0,0 +1,77 @@ +package cn.wanxiang.game.module.studio.service.studio; + +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioDraftCreateReqVO; +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioGenerateReqVO; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO; +import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO; + +import java.util.List; + +/** + * 创作工作台 Service 接口(创作主链路编排) + * + * 编排链路(MVP 轮询非 MQ,D4):模板浏览 → 建草稿(委托 project createProject) → 一句话生成(委托 aigc submitGenerate) + * → 进度轮询(透传 aigc getTask) → 重生成(委托 aigc retryTask)。 + * 边界(Doc B §71-76):不跑 LLM(委托 aigc)、不持久化项目版本(交 project)、不做锁风裁决(调 compliance)。 + * 归属边界:创作者只能操作自己的会话/任务链,归属校验在本 Service 强制(不依赖前端)。 + * + * @author 造梦AI + */ +public interface StudioService { + + /** + * 玩法模板列表(P-TPL-01/03 模板浏览,透传 aigc 模板注册表) + * + * @return 启用的模板列表 + */ + List getTemplateList(); + + /** + * 建草稿(P-CRT-10 进工作坊 + P-TPL-03 草稿移交 project) + * + * 编排:调 ProjectApi.createProject(creatorUserId, title, templateId) 建游戏拿 gameId 回写会话,再落会话(status=0 草稿)。 + * attachments 仅持久化(R7)。 + * + * @param reqVO 建草稿入参(title/templateId/prompt/attachments) + * @param userId 当前登录创作者 ID + * @return 新建会话 DO(含 id + 回写的 gameId) + */ + StudioSessionDO createDraft(StudioDraftCreateReqVO reqVO, Long userId); + + /** + * 一句话生成(P-CRT-01) + * + * 编排:校验 session 归属 → 落任务链(status=1 生成中) → 委托 AigcApi.submitGenerate(带 prompt/templateId/gameId/userId) + * → 回填 aigc_task_id → 会话置生成中。attachments 仅持久化、不入 aigc reqDTO(R7)。同步直写无 MQ(D4)。 + * + * @param reqVO 生成入参(sessionId/prompt/attachments) + * @param userId 当前登录创作者 ID + * @return 新建任务链 DO(含 id + aigc_task_id) + */ + StudioTaskChainDO generate(StudioGenerateReqVO reqVO, Long userId); + + /** + * 进度轮询(P-CRT-12,透传 aigc getTask) + * + * 编排:校验任务链归属 → 委托 AigcApi.getTask 取 aigc 进度/状态 → 映射任务链态 + * (aigc succeeded → 链完成回填 version_id + 会话完成;failed/timed_out → 链失败 + 会话失败)→ 回写后返回。 + * + * @param taskChainId 任务链 ID + * @param userId 当前登录创作者 ID + * @return 回写后的任务链 DO + */ + StudioTaskChainDO getTaskChain(Long taskChainId, Long userId); + + /** + * 重生成迭代(P-CRT-09) + * + * 编排:校验原任务链归属 → 委托 AigcApi.retryTask 重提生成 → 新建一条任务链(retry_of=原 id)回填新 aigc_task_id。 + * + * @param taskChainId 原任务链 ID + * @param userId 当前登录创作者 ID + * @return 新建的重试任务链 DO + */ + StudioTaskChainDO regenerate(Long taskChainId, Long userId); + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImpl.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImpl.java new file mode 100644 index 00000000..c03d74c3 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImpl.java @@ -0,0 +1,303 @@ +package cn.wanxiang.game.module.studio.service.studio; + +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioDraftCreateReqVO; +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioGenerateReqVO; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO; +import cn.wanxiang.game.module.studio.dal.mysql.studio.StudioSessionMapper; +import cn.wanxiang.game.module.studio.dal.mysql.studio.StudioTaskChainMapper; +import cn.wanxiang.game.module.studio.enums.StudioSessionStatusEnum; +import cn.wanxiang.game.module.aigc.api.AigcApi; +import cn.wanxiang.game.module.aigc.dto.AigcGenerateReqDTO; +import cn.wanxiang.game.module.aigc.dto.AigcGenerateRespDTO; +import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO; +import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO; +import cn.wanxiang.game.module.aigc.enums.AigcTaskStatusEnum; +import cn.wanxiang.game.module.project.api.ProjectApi; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Objects; + +import static cn.wanxiang.game.module.studio.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 创作工作台 Service 实现(创作主链路编排核心) + * + * 编排纪律(Doc B §71-76 + R2/R7/D4): + * - createDraft 调 project ProjectApi.createProject 建游戏(R2 createProject 是硬前置,不接受 game_id 恒空兜底); + * - generate/getTask/regenerate 委托 aigc AigcApi(生成权威归 aigc,studio 只持引用 + 链态); + * - 进度轮询非 MQ(D4,同步透传 aigc getTask); + * - attachments 仅持久化(R7),不入 aigc reqDTO。 + * 跨模块只依赖对方 -api(ProjectApi / AigcApi 走 Feign,MVP 单体由对方 @Primary 实现就地解析),禁依赖 -server。 + * 失败路径与异常均带中文日志(traceId 贯穿),归属校验失败抛 1-112-000-001。 + * + * @author 造梦AI + */ +@Service +public class StudioServiceImpl implements StudioService { + + private static final Logger log = LoggerFactory.getLogger(StudioServiceImpl.class); + + @Resource + private StudioSessionMapper studioSessionMapper; + + @Resource + private StudioTaskChainMapper studioTaskChainMapper; + + /** + * 编排依赖 project 的 -api(跨模块只依赖对方 -api 走 Feign,禁依赖 -server)。 + * MVP 单体:由 project 的 ProjectApiImpl(@RestController @Primary) 就地解析。 + */ + @Resource + private ProjectApi projectApi; + + /** + * 编排依赖 aigc 的 -api(Wave3 新增 seam)。MVP 单体:由 aigc 的 AigcApiImpl(@RestController @Primary) 就地解析。 + */ + @Resource + private AigcApi aigcApi; + + @Override + public List getTemplateList() { + // 模板浏览(P-TPL-01/03):透传 aigc 模板注册表(MVP 骨架返回空列表占位) + return aigcApi.getTemplateList().getCheckedData(); + } + + @Override + public StudioSessionDO createDraft(StudioDraftCreateReqVO reqVO, Long userId) { + // 模板必填兜底校验(VO 已 @NotBlank,此处服务端兜底) + if (!StringUtils.hasText(reqVO.getTemplateId())) { + throw exception(STUDIO_TEMPLATE_REQUIRED); + } + // R2 硬前置:调 project createProject 建游戏拿 gameId(不接受 game_id 恒空兜底);显式传 creatorUserId 作 owner + Long gameId; + try { + gameId = projectApi.createProject(userId, reqVO.getTitle(), reqVO.getTemplateId()).getCheckedData(); + } catch (Exception e) { + // project 调用异常(超时/错误码)→ 草稿移交失败,抛 studio 错误码(保留原异常链便于排障) + log.error("[createDraft] 草稿移交 project 建游戏失败 userId={} title={} templateId={}", + userId, reqVO.getTitle(), reqVO.getTemplateId(), e); + throw exception(STUDIO_PROJECT_CREATE_FAIL); + } + if (gameId == null) { + // 返回空 gameId 视为移交失败(硬前置不允许恒空兜底) + log.error("[createDraft] 草稿移交 project 返回空 gameId userId={} templateId={}", userId, reqVO.getTemplateId()); + throw exception(STUDIO_PROJECT_CREATE_FAIL); + } + // 落会话(status=0 草稿),回写移交后的 gameId;attachments 仅持久化(R7) + StudioSessionDO session = new StudioSessionDO(); + session.setCreatorUserId(userId); + session.setGameId(gameId); + session.setTemplateId(reqVO.getTemplateId()); + session.setTitle(reqVO.getTitle()); + session.setPrompt(reqVO.getPrompt()); + session.setAttachments(reqVO.getAttachments()); + session.setStatus(StudioSessionStatusEnum.DRAFT.getStatus()); + studioSessionMapper.insert(session); + log.info("[createDraft] 草稿已建 sessionId={} gameId={} userId={} templateId={}", + session.getId(), gameId, userId, reqVO.getTemplateId()); + return session; + } + + @Override + public StudioTaskChainDO generate(StudioGenerateReqVO reqVO, Long userId) { + // 1) 校验 session 存在 + 归属(创作者只能操作自己的会话) + StudioSessionDO session = validateSessionOwner(reqVO.getSessionId(), userId); + + // 2) attachments 仅持久化(R7):回写会话 prompt/attachments(不入 aigc reqDTO) + session.setPrompt(reqVO.getPrompt()); + if (reqVO.getAttachments() != null) { + session.setAttachments(reqVO.getAttachments()); + } + session.setStatus(StudioSessionStatusEnum.GENERATING.getStatus()); + studioSessionMapper.updateById(session); + + // 3) 落任务链(status=1 生成中),后续回填 aigc_task_id + StudioTaskChainDO chain = new StudioTaskChainDO(); + chain.setSessionId(session.getId()); + chain.setCreatorUserId(userId); + chain.setGameId(session.getGameId()); + chain.setStatus(StudioSessionStatusEnum.GENERATING.getStatus()); + chain.setProgress(0); + studioTaskChainMapper.insert(chain); + + // 4) 委托 aigc 提交生成(带 prompt/templateId/gameId/userId;attachments 不透传,R7)。同步直写无 MQ(D4) + AigcGenerateReqDTO req = new AigcGenerateReqDTO(); + req.setUserId(userId); + req.setPrompt(reqVO.getPrompt()); + req.setTemplateId(session.getTemplateId()); + req.setGameId(session.getGameId()); + AigcGenerateRespDTO resp; + try { + resp = aigcApi.submitGenerate(req).getCheckedData(); + } catch (Exception e) { + // aigc 调用异常 → 链态置失败 3 + 会话失败,抛 studio 错误码(中文日志,traceId 由 aigc 内部生成此处暂无) + markChainFailed(chain, session); + log.error("[generate] 生成委托 aigc 失败 sessionId={} userId={}", session.getId(), userId, e); + throw exception(STUDIO_AIGC_DELEGATE_FAIL); + } + if (resp == null || resp.getTaskId() == null) { + markChainFailed(chain, session); + log.error("[generate] 生成委托 aigc 返回空 taskId sessionId={} userId={}", session.getId(), userId); + throw exception(STUDIO_AIGC_DELEGATE_FAIL); + } + // 5) 回填 aigc_task_id + chain.setAigcTaskId(resp.getTaskId()); + studioTaskChainMapper.updateById(chain); + log.info("[generate] 生成已委托 aigc taskChainId={} aigcTaskId={} traceId={} sessionId={} userId={}", + chain.getId(), resp.getTaskId(), resp.getTraceId(), session.getId(), userId); + return chain; + } + + @Override + public StudioTaskChainDO getTaskChain(Long taskChainId, Long userId) { + // 1) 校验任务链存在 + 归属 + StudioTaskChainDO chain = validateTaskChainOwner(taskChainId, userId); + // 2) 未委托 aigc(aigc_task_id 为空,理论不可达)→ 直接返回当前态 + if (chain.getAigcTaskId() == null) { + return chain; + } + // 3) 委托 aigc getTask 取进度/状态(透传,非 MQ,D4) + AigcTaskRespDTO task; + try { + task = aigcApi.getTask(chain.getAigcTaskId(), userId).getCheckedData(); + } catch (Exception e) { + // 轮询 aigc 异常:不改链态(保留上次态供前端继续轮询),仅记日志后返回当前态 + log.warn("[getTaskChain] 轮询 aigc 异常,保留链态 taskChainId={} aigcTaskId={}", + taskChainId, chain.getAigcTaskId(), e); + return chain; + } + if (task == null) { + return chain; + } + // 4) 映射 aigc 态 → 任务链态 + 会话态 + chain.setProgress(task.getProgress() != null ? task.getProgress() : chain.getProgress()); + if (Objects.equals(AigcTaskStatusEnum.SUCCEEDED.getStatus(), task.getStatus())) { + // aigc 成功:链完成 + 回填 version_id(版本权威归 project,此处仅回填引用)+ 会话完成 + chain.setStatus(StudioSessionStatusEnum.DONE.getStatus()); + chain.setProgress(100); + chain.setVersionId(task.getVersionId()); + updateSessionStatus(chain.getSessionId(), StudioSessionStatusEnum.DONE.getStatus()); + log.info("[getTaskChain] 生成完成 taskChainId={} aigcTaskId={} versionId={}", + taskChainId, chain.getAigcTaskId(), task.getVersionId()); + } else if (Objects.equals(AigcTaskStatusEnum.FAILED.getStatus(), task.getStatus()) + || Objects.equals(AigcTaskStatusEnum.TIMED_OUT.getStatus(), task.getStatus())) { + // aigc 失败/超时:链失败 + 会话失败 + chain.setStatus(StudioSessionStatusEnum.FAILED.getStatus()); + updateSessionStatus(chain.getSessionId(), StudioSessionStatusEnum.FAILED.getStatus()); + log.warn("[getTaskChain] 生成失败 taskChainId={} aigcTaskId={} reason={}", + taskChainId, chain.getAigcTaskId(), task.getFailureReason()); + } + // queued/running/canceled:保持生成中链态,仅回写 progress + studioTaskChainMapper.updateById(chain); + return chain; + } + + @Override + public StudioTaskChainDO regenerate(Long taskChainId, Long userId) { + // 1) 校验原任务链存在 + 归属 + StudioTaskChainDO origin = validateTaskChainOwner(taskChainId, userId); + if (origin.getAigcTaskId() == null) { + // 原链未委托 aigc,无可重试 + throw exception(STUDIO_AIGC_DELEGATE_FAIL); + } + // 2) 委托 aigc retryTask 重提生成 + AigcGenerateRespDTO resp; + try { + resp = aigcApi.retryTask(origin.getAigcTaskId(), userId).getCheckedData(); + } catch (Exception e) { + log.error("[regenerate] 重生成委托 aigc 失败 taskChainId={} aigcTaskId={} userId={}", + taskChainId, origin.getAigcTaskId(), userId, e); + throw exception(STUDIO_AIGC_DELEGATE_FAIL); + } + if (resp == null || resp.getTaskId() == null) { + log.error("[regenerate] 重生成委托 aigc 返回空 taskId taskChainId={} userId={}", taskChainId, userId); + throw exception(STUDIO_AIGC_DELEGATE_FAIL); + } + // 3) 新建一条任务链(retry_of=原 id,记录重生成血缘),回填新 aigc_task_id;会话置生成中 + StudioTaskChainDO retry = new StudioTaskChainDO(); + retry.setSessionId(origin.getSessionId()); + retry.setCreatorUserId(userId); + retry.setGameId(origin.getGameId()); + retry.setAigcTaskId(resp.getTaskId()); + retry.setStatus(StudioSessionStatusEnum.GENERATING.getStatus()); + retry.setProgress(0); + retry.setRetryOf(origin.getId()); + studioTaskChainMapper.insert(retry); + updateSessionStatus(origin.getSessionId(), StudioSessionStatusEnum.GENERATING.getStatus()); + log.info("[regenerate] 重生成已委托 aigc newTaskChainId={} retryOf={} aigcTaskId={} traceId={} userId={}", + retry.getId(), origin.getId(), resp.getTaskId(), resp.getTraceId(), userId); + return retry; + } + + // ============================== 私有校验/工具 ============================== + + /** + * 校验会话存在且归属当前用户 + * + * @param sessionId 会话 ID + * @param userId 当前用户 ID + * @return 会话 DO + */ + private StudioSessionDO validateSessionOwner(Long sessionId, Long userId) { + StudioSessionDO session = studioSessionMapper.selectById(sessionId); + if (session == null) { + throw exception(STUDIO_SESSION_NOT_EXISTS); + } + // 数据边界:创作者只能操作自己的会话(服务端强校验,不依赖前端) + if (!Objects.equals(session.getCreatorUserId(), userId)) { + throw exception(STUDIO_SESSION_NOT_OWNER); + } + return session; + } + + /** + * 校验任务链存在且归属当前用户 + * + * @param taskChainId 任务链 ID + * @param userId 当前用户 ID + * @return 任务链 DO + */ + private StudioTaskChainDO validateTaskChainOwner(Long taskChainId, Long userId) { + StudioTaskChainDO chain = studioTaskChainMapper.selectById(taskChainId); + if (chain == null) { + throw exception(STUDIO_TASKCHAIN_NOT_EXISTS); + } + if (!Objects.equals(chain.getCreatorUserId(), userId)) { + throw exception(STUDIO_SESSION_NOT_OWNER); + } + return chain; + } + + /** + * 标记任务链失败 + 会话失败(生成委托异常路径复用) + * + * @param chain 任务链 DO(已落库) + * @param session 会话 DO + */ + private void markChainFailed(StudioTaskChainDO chain, StudioSessionDO session) { + chain.setStatus(StudioSessionStatusEnum.FAILED.getStatus()); + studioTaskChainMapper.updateById(chain); + updateSessionStatus(session.getId(), StudioSessionStatusEnum.FAILED.getStatus()); + } + + /** + * 回写会话状态(仅状态位更新,避免整对象覆盖) + * + * @param sessionId 会话 ID + * @param status 目标状态 + */ + private void updateSessionStatus(Long sessionId, Integer status) { + StudioSessionDO update = new StudioSessionDO(); + update.setId(sessionId); + update.setStatus(status); + studioSessionMapper.updateById(update); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/resources/db/migration/V8.0.0__create_game_studio.sql b/game-cloud/game-module-studio/game-module-studio-server/src/main/resources/db/migration/V8.0.0__create_game_studio.sql new file mode 100644 index 00000000..9e2f2e0b --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/resources/db/migration/V8.0.0__create_game_studio.sql @@ -0,0 +1,92 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:studio(game-module-studio,Wave3 创作主链路最小版)| owner:studio 路 +-- 文件:V8.0.0__create_game_studio.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V8.0.1 DROP TABLE) +-- 内容:studio 模块核心表 —— 创作会话 game_studio_session + 创作任务链 game_studio_taskchain + 资产槽位 game_studio_asset +-- 职责(架构 Doc B §71-76):有状态创作工作台后端。编排「建草稿(委托 project createProject)→一句话生成(委托 aigc) +-- →进度轮询→重生成→模板应用」。不跑 LLM(委托 aigc)、不持久化项目版本(交 project)、不做锁风裁决(调 compliance)。 +-- 边界纪律: +-- - 项目/版本权威归 project(game_project / game_version);studio 仅持 game_id/version_id 引用,不在本模块建版本表。 +-- - 生成任务权威归 aigc(game_aigc_task);studio 任务链仅持 aigc_task_id 引用 + 自身链态,不在本模块重建生成状态机。 +-- - attachments 本波仅持久化(R7),不透传 aigc(aigc reqDTO 无 attachments 字段)。 +-- - MVP 进度推送走轮询(D4),非 MQ/SSE。 +-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id; +-- 状态机用 tinyint,非法流转由 Service 校验,DO 层不承载;中文列注释;关键查询建索引、禁裸 select*。 +-- 错误码段:studio = 1-112-***-*** +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 表:game_studio_session —— 创作会话表(有状态创作工作台主记录,对齐 P-CRT-10 进工作坊) +-- 用途:创作者进工作坊 upsert 一条会话,承载草稿入参(title/prompt/templateId/attachments)+ 移交后的 game_id。 +-- 移交:createDraft 调 project ProjectApi.createProject 拿 gameId 回写本表 game_id(草稿移交 project,P-TPL-03)。 +-- attachments:JSON 持久化(本波仅持久化不透传 aigc,R7)。 +-- 状态机 status:0 草稿 → 1 生成中 → 2 完成 / 3 失败(StudioSessionStatusEnum;由 generate/getTask 透传 aigc 终态映射)。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_studio_session` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '创作会话 ID', + `creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(业务字段;DataPermission:创作者只见自己会话。区别于审计列 creator)', + `game_id` BIGINT NULL DEFAULT NULL COMMENT '移交后的游戏 ID(project.game_project.id);createDraft 调 createProject 后回写', + `template_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '玩法模板 ID(透传 aigc,如 dodge/runner/clicker/puzzle/shooter)', + `title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '作品标题(建草稿入参,透传 project createProject)', + `prompt` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '一句话自然语言 Prompt(透传 aigc submitGenerate)', + `attachments` JSON NULL DEFAULT NULL COMMENT '附件上下文(P-CRT-04,JSON 数组);本波仅持久化,P1 透传 aigc', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '会话状态机:0草稿 1生成中 2完成 3失败(StudioSessionStatusEnum;Service 校验流转)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `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`), + KEY `idx_creator` (`creator_user_id`, `id`) COMMENT '创作者「我的创作会话」列表(DataPermission 边界 + 按 id 倒序)', + KEY `idx_game` (`game_id`) COMMENT '按移交后的游戏 ID 反查会话' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作会话表(有状态创作工作台主记录;草稿入参 + 移交 game_id)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_studio_taskchain —— 创作任务链表(线性单步编排,对齐 P-CRT-01/09 一句话生成 + 重生成血缘) +-- 用途:generate 时落一条任务链(status=1),回填 aigc_task_id;getTask 轮询透传 aigc 进度/状态映射链态。 +-- 引用边界:aigc_task_id 引用 aigc.game_aigc_task.id(生成权威归 aigc);version_id 引用 project.game_version.id +-- (aigc succeeded 后回填,版本权威归 project);本表只持引用 + 自身链态,不重建生成状态机。 +-- 重生成血缘:retry_of 指向原任务链 id(P-CRT-09);regenerate 新建一条 retry_of=原 id 的链。 +-- 链态 status:0 初始 → 1 生成中 → 2 完成(回填 version_id) / 3 失败(透传 aigc failed/timed_out)。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_studio_taskchain` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '创作任务链 ID', + `session_id` BIGINT NOT NULL COMMENT '所属创作会话 ID(= game_studio_session.id)', + `creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(DataPermission 边界,归属校验用)', + `aigc_task_id` BIGINT NULL DEFAULT NULL COMMENT '委托的 aigc 生成任务 ID(= aigc.game_aigc_task.id);generate 后回填,轮询据此查 aigc', + `game_id` BIGINT NULL DEFAULT NULL COMMENT '关联游戏 ID(project.game_project.id,透传 aigc 用)', + `version_id` BIGINT NULL DEFAULT NULL COMMENT '产物版本 ID(project.game_version.id);aigc succeeded 后由 getTask 回填', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '任务链状态机:0初始 1生成中 2完成 3失败(Service 校验流转,透传 aigc 终态映射)', + `progress` INT NOT NULL DEFAULT 0 COMMENT '进度百分比 0-100(透传 aigc progress,供前端进度条 P-CRT-12)', + `retry_of` BIGINT NULL DEFAULT NULL COMMENT '若为重生成,指向原任务链 ID(P-CRT-09 重生成血缘)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `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`), + KEY `idx_session` (`session_id`, `id`) COMMENT '按会话查任务链(含重生成历史,按 id 倒序)', + KEY `idx_aigc` (`aigc_task_id`) COMMENT '按 aigc 任务 ID 反查任务链(轮询回写映射)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作任务链表(线性单步编排;持 aigc_task_id/version_id 引用 + 链态)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_studio_asset —— 创作资产槽位表(单步出整包,对齐 P-CRT-02;MVP 仅落槽位 seam,不六路并发) +-- 用途:单步生成产物的资产槽位记录(asset_type 槽位 + asset_url + 槽位状态)。 +-- 狠切(R7/草稿 scope):MVP 只建槽位表占位 seam,不实现六路并发资产生成;资产由 aigc 单步整包产出,studio 仅登记槽位。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_studio_asset` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '资产槽位 ID', + `session_id` BIGINT NOT NULL COMMENT '所属创作会话 ID(= game_studio_session.id)', + `asset_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '资产槽位类型(如 sprite/background/audio/icon;MVP 槽位占位)', + `asset_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '资产 URL(OSS/MinIO;单步整包产出后登记)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '槽位状态:0未生成 1已生成(Service 校验,MVP 单步直填)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者', + `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`), + KEY `idx_session_type` (`session_id`, `asset_type`) COMMENT '按会话+槽位类型查资产' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作资产槽位表(单步出整包槽位 seam;MVP 不六路并发)'; diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/test/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImplTest.java b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImplTest.java new file mode 100644 index 00000000..7a68e18c --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/cn/wanxiang/game/module/studio/service/studio/StudioServiceImplTest.java @@ -0,0 +1,296 @@ +package cn.wanxiang.game.module.studio.service.studio; + +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioDraftCreateReqVO; +import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioGenerateReqVO; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO; +import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO; +import cn.wanxiang.game.module.studio.dal.mysql.studio.StudioSessionMapper; +import cn.wanxiang.game.module.studio.dal.mysql.studio.StudioTaskChainMapper; +import cn.wanxiang.game.module.studio.enums.StudioSessionStatusEnum; +import cn.wanxiang.game.module.aigc.api.AigcApi; +import cn.wanxiang.game.module.aigc.dto.AigcGenerateReqDTO; +import cn.wanxiang.game.module.aigc.dto.AigcGenerateRespDTO; +import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO; +import cn.wanxiang.game.module.aigc.enums.AigcTaskStatusEnum; +import cn.wanxiang.game.module.project.api.ProjectApi; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static cn.wanxiang.game.module.studio.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link StudioServiceImpl} 单元测试(纯 Mockito,不依赖 DB) + * + * 覆盖创作主链路编排核心: + * - createDraft 委托 project-api(createProject 拿 gameId 回写会话;调用异常/空 gameId 抛错); + * - generate 委托 aigc(落任务链 + submitGenerate 回填 aigc_task_id;attachments 不透传 aigc,R7); + * - getTaskChain 状态流转(aigc succeeded→链完成回填 version_id;failed→链失败); + * - regenerate 委托 aigc retryTask 新建 retry_of 任务链; + * - 归属校验(创作者只见自己会话/任务链)。 + * mock BaseMapper.insert 用 any(*.class) 消歧(裸 any() 会因重载报错)。 + * + * @author 造梦AI + */ +class StudioServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private StudioServiceImpl studioService; + + @Mock + private StudioSessionMapper studioSessionMapper; + @Mock + private StudioTaskChainMapper studioTaskChainMapper; + @Mock + private ProjectApi projectApi; + @Mock + private AigcApi aigcApi; + + private static final Long USER_ID = 99L; + + // ============================== createDraft 委托 project-api ============================== + + @Test + void testCreateDraft_delegatesProjectCreate_andWritesBackGameId() { + // project createProject 返回 gameId=2048 + when(projectApi.createProject(eq(USER_ID), eq("我的放置小镇"), eq("idle"))) + .thenReturn(CommonResult.success(2048L)); + // insert 回填会话 ID + doAnswer(inv -> { + StudioSessionDO d = inv.getArgument(0); + d.setId(512L); + return 1; + }).when(studioSessionMapper).insert(any(StudioSessionDO.class)); + + StudioSessionDO session = studioService.createDraft(draftReq("我的放置小镇", "idle"), USER_ID); + + // 断言:委托 project 建游戏拿 gameId 回写会话;落库 status=0 草稿 + assertEquals(2048L, session.getGameId()); + assertEquals(USER_ID, session.getCreatorUserId()); + assertEquals(StudioSessionStatusEnum.DRAFT.getStatus(), session.getStatus()); + verify(projectApi).createProject(USER_ID, "我的放置小镇", "idle"); + ArgumentCaptor captor = ArgumentCaptor.forClass(StudioSessionDO.class); + verify(studioSessionMapper).insert(captor.capture()); + assertEquals(2048L, captor.getValue().getGameId()); + } + + @Test + void testCreateDraft_projectReturnsNullGameId_throws() { + // project 返回空 gameId(硬前置不允许恒空兜底)→ 抛移交失败错误码,不落会话 + when(projectApi.createProject(any(), any(), any())).thenReturn(CommonResult.success(null)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.createDraft(draftReq("t", "idle"), USER_ID)); + assertEquals(STUDIO_PROJECT_CREATE_FAIL.getCode(), ex.getCode()); + verify(studioSessionMapper, never()).insert(any(StudioSessionDO.class)); + } + + @Test + void testCreateDraft_templateRequired_throws() { + // 模板为空 → 服务端兜底校验抛错,不调 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()); + } + + // ============================== generate 委托 aigc ============================== + + @Test + void testGenerate_delegatesAigc_andWritesBackAigcTaskId() { + // 会话存在且归属 + StudioSessionDO session = session(512L, USER_ID, 2048L, "idle"); + when(studioSessionMapper.selectById(512L)).thenReturn(session); + // 任务链 insert 回填 ID + doAnswer(inv -> { + StudioTaskChainDO d = inv.getArgument(0); + d.setId(1000L); + return 1; + }).when(studioTaskChainMapper).insert(any(StudioTaskChainDO.class)); + // aigc submitGenerate 返回 taskId=8888 + AigcGenerateRespDTO resp = new AigcGenerateRespDTO(); + resp.setTaskId(8888L); + resp.setStatus(AigcTaskStatusEnum.QUEUED.getStatus()); + resp.setTraceId("aigc-trace-1"); + when(aigcApi.submitGenerate(any(AigcGenerateReqDTO.class))).thenReturn(CommonResult.success(resp)); + + StudioTaskChainDO chain = studioService.generate(generateReq(512L, "做一个躲避陨石的小游戏", "[\"a.png\"]"), USER_ID); + + // 断言:委托 aigc 提交生成,回填 aigc_task_id;链态生成中 + assertEquals(8888L, chain.getAigcTaskId()); + assertEquals(StudioSessionStatusEnum.GENERATING.getStatus(), chain.getStatus()); + // 校验透传 aigc 的入参:带 prompt/templateId/gameId/userId;attachments 不在 reqDTO(R7) + ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(AigcGenerateReqDTO.class); + verify(aigcApi).submitGenerate(reqCaptor.capture()); + AigcGenerateReqDTO sent = reqCaptor.getValue(); + assertEquals(USER_ID, sent.getUserId()); + assertEquals("做一个躲避陨石的小游戏", sent.getPrompt()); + assertEquals("idle", sent.getTemplateId()); + assertEquals(2048L, sent.getGameId()); + // attachments 仅持久化(R7):回写会话 attachments,AigcGenerateReqDTO 无此字段(编译期即保证不透传) + verify(studioSessionMapper, atLeastOnce()).updateById(any(StudioSessionDO.class)); + } + + @Test + void testGenerate_sessionNotOwner_throws() { + // 会话属于他人 → 归属校验失败 + StudioSessionDO others = session(512L, 1L, 2048L, "idle"); + when(studioSessionMapper.selectById(512L)).thenReturn(others); + + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.generate(generateReq(512L, "p", null), USER_ID)); + assertEquals(STUDIO_SESSION_NOT_OWNER.getCode(), ex.getCode()); + verify(aigcApi, never()).submitGenerate(any()); + } + + @Test + void testGenerate_aigcReturnsNull_marksFailed_andThrows() { + 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 返回空 taskId → 链失败 + 抛错 + AigcGenerateRespDTO resp = new AigcGenerateRespDTO(); + when(aigcApi.submitGenerate(any())).thenReturn(CommonResult.success(resp)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.generate(generateReq(512L, "p", null), USER_ID)); + assertEquals(STUDIO_AIGC_DELEGATE_FAIL.getCode(), ex.getCode()); + } + + // ============================== getTaskChain 状态流转 ============================== + + @Test + void testGetTaskChain_aigcSucceeded_chainDone_versionIdWrittenBack() { + StudioTaskChainDO chain = taskChain(1000L, 512L, USER_ID, 8888L); + when(studioTaskChainMapper.selectById(1000L)).thenReturn(chain); + // aigc 成功,回填 version_id=4096 + AigcTaskRespDTO task = aigcTask(AigcTaskStatusEnum.SUCCEEDED.getStatus(), 100, 4096L); + when(aigcApi.getTask(8888L, USER_ID)).thenReturn(CommonResult.success(task)); + + StudioTaskChainDO result = studioService.getTaskChain(1000L, USER_ID); + + // 断言:链完成 + 回填 version_id + 会话置完成 + assertEquals(StudioSessionStatusEnum.DONE.getStatus(), result.getStatus()); + assertEquals(4096L, result.getVersionId()); + assertEquals(100, result.getProgress()); + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(StudioSessionDO.class); + verify(studioSessionMapper).updateById(sessionCaptor.capture()); + assertEquals(StudioSessionStatusEnum.DONE.getStatus(), sessionCaptor.getValue().getStatus()); + } + + @Test + void testGetTaskChain_aigcFailed_chainFailed() { + StudioTaskChainDO chain = taskChain(1000L, 512L, USER_ID, 8888L); + when(studioTaskChainMapper.selectById(1000L)).thenReturn(chain); + AigcTaskRespDTO task = aigcTask(AigcTaskStatusEnum.FAILED.getStatus(), 40, null); + when(aigcApi.getTask(8888L, USER_ID)).thenReturn(CommonResult.success(task)); + + StudioTaskChainDO result = studioService.getTaskChain(1000L, USER_ID); + + assertEquals(StudioSessionStatusEnum.FAILED.getStatus(), result.getStatus()); + assertNull(result.getVersionId()); + } + + @Test + void testGetTaskChain_aigcRunning_keepsGenerating() { + StudioTaskChainDO chain = taskChain(1000L, 512L, USER_ID, 8888L); + chain.setStatus(StudioSessionStatusEnum.GENERATING.getStatus()); + when(studioTaskChainMapper.selectById(1000L)).thenReturn(chain); + AigcTaskRespDTO task = aigcTask(AigcTaskStatusEnum.RUNNING.getStatus(), 50, null); + when(aigcApi.getTask(8888L, USER_ID)).thenReturn(CommonResult.success(task)); + + StudioTaskChainDO result = studioService.getTaskChain(1000L, USER_ID); + + // 非终态:保持生成中,仅回写进度 + assertEquals(StudioSessionStatusEnum.GENERATING.getStatus(), result.getStatus()); + assertEquals(50, result.getProgress()); + verify(studioSessionMapper, never()).updateById(any(StudioSessionDO.class)); + } + + // ============================== regenerate 委托 aigc retryTask ============================== + + @Test + void testRegenerate_delegatesRetry_newChainWithRetryOf() { + StudioTaskChainDO origin = taskChain(1000L, 512L, USER_ID, 8888L); + when(studioTaskChainMapper.selectById(1000L)).thenReturn(origin); + doAnswer(inv -> { ((StudioTaskChainDO) inv.getArgument(0)).setId(1001L); return 1; }) + .when(studioTaskChainMapper).insert(any(StudioTaskChainDO.class)); + AigcGenerateRespDTO resp = new AigcGenerateRespDTO(); + resp.setTaskId(9999L); + when(aigcApi.retryTask(8888L, USER_ID)).thenReturn(CommonResult.success(resp)); + + StudioTaskChainDO retry = studioService.regenerate(1000L, USER_ID); + + // 断言:新建任务链 retry_of=原 id,回填新 aigc_task_id + assertEquals(1000L, retry.getRetryOf()); + assertEquals(9999L, retry.getAigcTaskId()); + assertEquals(StudioSessionStatusEnum.GENERATING.getStatus(), retry.getStatus()); + verify(aigcApi).retryTask(8888L, USER_ID); + } + + @Test + void testGetTaskChain_taskChainNotExists_throws() { + when(studioTaskChainMapper.selectById(1000L)).thenReturn(null); + ServiceException ex = assertThrows(ServiceException.class, + () -> studioService.getTaskChain(1000L, USER_ID)); + assertEquals(STUDIO_TASKCHAIN_NOT_EXISTS.getCode(), ex.getCode()); + } + + // ============================== 测试夹具 ============================== + + private static StudioDraftCreateReqVO draftReq(String title, String templateId) { + StudioDraftCreateReqVO reqVO = new StudioDraftCreateReqVO(); + reqVO.setTitle(title); + reqVO.setTemplateId(templateId); + reqVO.setPrompt("做一个躲避陨石的小游戏"); + return reqVO; + } + + private static StudioGenerateReqVO generateReq(Long sessionId, String prompt, String attachments) { + StudioGenerateReqVO reqVO = new StudioGenerateReqVO(); + reqVO.setSessionId(sessionId); + reqVO.setPrompt(prompt); + reqVO.setAttachments(attachments); + return reqVO; + } + + private static StudioSessionDO session(Long id, Long creatorUserId, Long gameId, String templateId) { + StudioSessionDO s = new StudioSessionDO(); + s.setId(id); + s.setCreatorUserId(creatorUserId); + s.setGameId(gameId); + s.setTemplateId(templateId); + s.setStatus(StudioSessionStatusEnum.DRAFT.getStatus()); + return s; + } + + private static StudioTaskChainDO taskChain(Long id, Long sessionId, Long creatorUserId, Long aigcTaskId) { + StudioTaskChainDO c = new StudioTaskChainDO(); + c.setId(id); + c.setSessionId(sessionId); + c.setCreatorUserId(creatorUserId); + c.setAigcTaskId(aigcTaskId); + c.setGameId(2048L); + c.setStatus(StudioSessionStatusEnum.GENERATING.getStatus()); + c.setProgress(0); + return c; + } + + private static AigcTaskRespDTO aigcTask(Integer status, Integer progress, Long versionId) { + AigcTaskRespDTO t = new AigcTaskRespDTO(); + t.setStatus(status); + t.setProgress(progress); + t.setVersionId(versionId); + return t; + } + +} diff --git a/game-cloud/game-module-studio/pom.xml b/game-cloud/game-module-studio/pom.xml new file mode 100644 index 00000000..ccb0fdcb --- /dev/null +++ b/game-cloud/game-module-studio/pom.xml @@ -0,0 +1,28 @@ + + + + + cn.iocoder.cloud + yudao + ${revision} + + 4.0.0 + + + game-module-studio-api + game-module-studio-server + + game-module-studio + pom + + ${project.artifactId} + + 造梦AI - studio 模块(创作主链路最小版,与 game-studio 产品端前端仓区分):有状态创作工作台后端, + 编排「建草稿(委托 project createProject)→一句话生成(委托 aigc)→进度轮询→重生成→模板应用」。 + 不跑 LLM(委托 aigc)、不持久化项目版本(交 project)、不做锁风裁决(调 compliance)。 + MVP 轮询非 MQ;attachments 本波仅持久化不透传 aigc。错误码段 = 1-112-***-***,Flyway = V8。 + + + diff --git a/game-cloud/pom.xml b/game-cloud/pom.xml index 733b0900..8ed35595 100644 --- a/game-cloud/pom.xml +++ b/game-cloud/pom.xml @@ -29,6 +29,9 @@ game-module-ad game-module-trade + + game-module-compliance + game-module-studio ${project.artifactId} diff --git a/game-cloud/yudao-server/pom.xml b/game-cloud/yudao-server/pom.xml index 0478957f..0d9cde0b 100644 --- a/game-cloud/yudao-server/pom.xml +++ b/game-cloud/yudao-server/pom.xml @@ -69,6 +69,17 @@ game-module-trade-server ${revision} + + + cn.iocoder.cloud + game-module-compliance-server + ${revision} + + + cn.iocoder.cloud + game-module-studio-server + ${revision} + org.flywaydb diff --git a/game-studio/src/api/project.ts b/game-studio/src/api/project.ts index 0d9db3cc..023257c5 100644 --- a/game-studio/src/api/project.ts +++ b/game-studio/src/api/project.ts @@ -8,6 +8,7 @@ import type { ProjectCreateReq, ProjectDraftReq, ProjectPublishReq, + ProjectPublishResp, } from './types' /** @@ -67,13 +68,20 @@ export function saveDraft(id: number, dto: ProjectDraftReq): Promise { } /** - * 提交发布(发布前 7 门禁 → 进审核 BPM)。 + * 提交发布(发布前门禁聚合 → 进审核)。 * POST /app-api/project/{id}/publish + * + * 契约(GAP-1,in-place 破坏性升级,无并存期):响应由 boolean 升级为结构化信封 + * {@link ProjectPublishResp}(admitted + gates[])。归属/草稿态为安全前置,失败由后端抛 + * 业务错误码(code≠0),经 request.ts 走 reject(前端 catch 失败态);发布前门禁聚合为 gates: + * 全部通过则 admitted=true 并置 status=1 审核中;任一拦截则 admitted=false 且不改状态机。 + * * @param id 项目 ID * @param dto 发布请求体(versionId + launchZoneId) + * @returns 发布门禁聚合结果(admitted + 逐项 gates) */ -export function publish(id: number, dto: ProjectPublishReq): Promise { - return request({ +export function publish(id: number, dto: ProjectPublishReq): Promise { + return request({ url: `/app-api/project/${id}/publish`, method: 'post', data: dto, diff --git a/game-studio/src/api/types.ts b/game-studio/src/api/types.ts index 71da1e61..b005e933 100644 --- a/game-studio/src/api/types.ts +++ b/game-studio/src/api/types.ts @@ -88,6 +88,11 @@ export interface InteractResult { /** 分享落地页 OG 元数据(feed.yaml ShareMetaRespVO) */ export interface ShareMeta { gameId: number + /** + * 当前生效版本 ID(GAP-3,additive):供分享落地页「立即玩」携带 versionId 跳 /play/:gameId/:versionId。 + * 仅当游戏已发布(isGamePublished)时由后端回填;未发布则缺省(前端兜底空串)。 + */ + versionId?: number /** 独立分享链接(含 utm/channel 渠道参数) */ shareUrl: string ogTitle: string @@ -196,6 +201,11 @@ export interface RuntimePackage { templateId: string /** GamePackage manifest URL */ packageUrl: string + /** + * manifest.json 直取 URL(GAP-2,additive):宿主据此 fetch 并按 checksum(sha256) 校验后注入 iframe。 + * 后端由 packageUrl 同前缀派生(.../manifest.json)或单列回填;缺省时宿主退回 demo 兜底(见 GamePlayer.resolvePackage)。 + */ + manifestUrl?: string /** 入口文件相对路径(manifest.entry) */ entry: string /** 目标 SDK / Runtime 版本(semver) */ @@ -302,6 +312,37 @@ export interface ProjectPublishReq { launchZoneId: number } +/** + * 发布前门禁单项结果(project.yaml GateResultVO,逐字段镜像后端)。 + * 一条 gate = 一项发布前检查(如 age 适龄提示 / compliance 合规扫描)。 + * pass=false 表示该项拦截,code/message 给出可回显给创作者的错误码与文案。 + */ +export interface GateResult { + /** 门禁键(稳定标识,前端据此定位/高亮);如 status/title/cover/version/age/zone/compliance */ + key: string + /** 门禁中文标签(展示用) */ + label?: string + /** 是否通过:true 通过 / false 拦截 */ + pass: boolean + /** 拦截时的错误码字符串(pass=true 时缺省) */ + code?: string + /** 拦截时的提示文案(pass=true 时缺省) */ + message?: string +} + +/** + * 提交发布响应(project.yaml ProjectPublishRespVO,发布前门禁聚合结果)。 + * - admitted=true:全部 gate 通过,状态机已置「审核中」; + * - admitted=false:至少一条 gate 拦截,状态机未改动(无副作用),创作者据 gates 修复后重提。 + * 注意:归属/草稿态等「安全前置」失败仍由后端抛业务错误码(不进本结构),前端走 catch 失败态。 + */ +export interface ProjectPublishResp { + /** 是否准入(全部门禁通过=true;任一拦截=false 且不改状态机) */ + admitted: boolean + /** 发布前门禁逐项结果(含通过与拦截项,供前端逐条回显) */ + gates: GateResult[] +} + /* ============================== telemetry.yaml ============================== */ /** diff --git a/game-studio/src/host/GamePlayer.vue b/game-studio/src/host/GamePlayer.vue index ed6e9462..7b8b1444 100644 --- a/game-studio/src/host/GamePlayer.vue +++ b/game-studio/src/host/GamePlayer.vue @@ -4,8 +4,9 @@ * ---------------------------------------------------------------------------- * 职责(spec §3.4 / AC5 / AC6): * 1. iframe 沙箱:设 sandbox="allow-scripts allow-same-origin"(security §1.1)。 - * 2. 取包:传入 manifest 则直接用;否则按 versionId 调 runtime.getPackage(契约 #1)。 - * 3. 注入:SDK + Runtime + demo → iframe srcdoc 内联(inject.ts)。 + * 2. 取包(GAP-2):传入 manifest 则直接用;否则按 versionId 调 runtime.getPackage(契约 #1)取清单, + * 若清单含 manifestUrl 则 fetch 并按 checksum(sha256) 校验后注入真包;无 manifestUrl 才退回 demo 兜底。 + * 3. 注入:SDK + Runtime + GamePackage → iframe srcdoc 内联(inject.ts)。 * 4. 挂 bridge:postMessage 收发 + origin/schema 双校验 + ad/pay requestId 配对。 * 5. 三容器(当前/上/下)预加载占位(竖屏刷流体验占位,骨架阶段为视觉占位)。 * 6. ad/pay 桩弹层:点击模拟激励视频/支付,回调 rewarded:true / paid:true。 @@ -29,7 +30,7 @@ import { type RuntimePackageRespVO, } from './contract'; import { HostBridge, buildOriginAllowlist, type RejectReason } from './bridge'; -import { buildIframeSrcdoc, buildDemoPackage } from './inject'; +import { buildIframeSrcdoc, buildDemoPackage, fetchAndVerifyManifest } from './inject'; import { getRuntimePackage } from './runtimeApi'; // —— props(spec §3.4)—— @@ -96,30 +97,52 @@ const containers = ['prev', 'current', 'next'] as const; * 取包 + 注入主流程 * ========================================================================== */ -/** 取游戏包:优先 props.manifest;否则按 versionId 走契约端点;失败给 demo 兜底(骨架自测) */ +/** + * 取游戏包(GAP-2): + * 1) 优先 props.manifest(创作预览直传,不走网络)。 + * 2) 否则按 versionId 调 runtime.getPackage 取「运行包清单」(契约 #1 RuntimePackageRespVO): + * - 清单含 manifestUrl → fetch manifest.json 并按 checksum(sha256) 完整性校验后注入真包(T-RT-15); + * 校验/拉取失败 → emit('error') 并退回 demo 兜底(不白屏)。 + * - 清单无 manifestUrl(如 mock 阶段后端尚未回填)→ 退回 demo 兜底,并标注这是「无 manifestUrl 兜底」。 + * 3) versionId 非法 → demo 兜底。 + */ async function resolvePackage(): Promise { // 1) 直接传入的 manifest(创作预览常用) if (props.manifest) return props.manifest; // 2) 按 versionId 调 runtime.getPackage(契约 #1 RuntimePackageRespVO) - // 注:契约返回的是「运行包清单」(manifest URL 等),骨架阶段没有真实 OSS 包可下载, - // 故此处取到清单后,仍用 demo 游戏包驱动 Runtime(assets 为空的 clicker), - // 保证本地 mock 链路可真跑通;真实联调时改为按 packageUrl 拉取并解析为 GamePackage。 const vid = Number(props.versionId); if (Number.isFinite(vid)) { + let respVO: RuntimePackageRespVO; try { const fetcher = props.fetchPackage ?? getRuntimePackage; - const respVO: RuntimePackageRespVO = await fetcher(vid, props.mode); - // 清单拿到即认为取包成功;用 respVO 的 trace/template 充实 demo 包元信息。 - const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value); - demo.templateId = respVO.templateId || demo.templateId; - return demo; + respVO = await fetcher(vid, props.mode); } catch (e) { - // 取包失败:抛给上层 error,并以 demo 兜底(骨架阶段不白屏,便于自测链路) - const message = e instanceof Error ? e.message : '取包失败'; + // 取清单失败:抛给上层 error,并以 demo 兜底(不白屏,便于自测链路) + const message = e instanceof Error ? e.message : '取包清单失败'; emit('error', { message }); return buildDemoPackage(props.gameId, props.versionId, traceId.value); } + + // 2a) 清单含 manifestUrl:拉取真包 + checksum 完整性校验后注入 + if (respVO.manifestUrl) { + try { + return await fetchAndVerifyManifest(respVO.manifestUrl, respVO.checksum); + } catch (e) { + // 拉取/校验失败:上报 error,退回 demo 兜底(去无条件 demo,仅失败时兜底) + const message = e instanceof Error ? e.message : 'manifest 取包/校验失败'; + emit('error', { message }); + const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value); + demo.templateId = respVO.templateId || demo.templateId; + return demo; + } + } + + // 2b) 清单无 manifestUrl(后端尚未回填 / mock 阶段):退回 demo 兜底,用清单元信息充实 + // —— GAP-2 约定:仅在无 manifestUrl 时兜底(去掉原「无条件 demo」逻辑)。 + const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value); + demo.templateId = respVO.templateId || demo.templateId; + return demo; } // 3) versionId 非法:demo 兜底 diff --git a/game-studio/src/host/contract.ts b/game-studio/src/host/contract.ts index d572ecf7..09becdab 100644 --- a/game-studio/src/host/contract.ts +++ b/game-studio/src/host/contract.ts @@ -198,6 +198,12 @@ export interface RuntimePackageRespVO { versionId: number; templateId: string; packageUrl: string; + /** + * manifest.json 直取 URL(GAP-2,additive,对齐 runtime.yaml RuntimePackageRespVO.manifestUrl)。 + * 宿主据此 fetch 解析为 GamePackage(#4),并按本结构 checksum 做 sha256 完整性校验后注入 iframe(T-RT-15)。 + * 后端由 packageUrl 同前缀派生或单列回填;缺省时宿主退回 demo 兜底(见 GamePlayer.resolvePackage)。 + */ + manifestUrl?: string; entry: string; runtimeVersion: string; preloadPolicy: 'eager' | 'lazy'; diff --git a/game-studio/src/host/inject.ts b/game-studio/src/host/inject.ts index 3ce2988a..9381dbab 100644 --- a/game-studio/src/host/inject.ts +++ b/game-studio/src/host/inject.ts @@ -132,6 +132,72 @@ function safeJson(obj: unknown): string { return JSON.stringify(obj).replace(//g, '\\u003e'); } +/** + * 计算字符串的 sha256(hex 小写)。用 Web Crypto SubtleCrypto(浏览器/安全上下文可用)。 + * @param text 待摘要的原始文本(manifest.json 原始响应体) + * @returns 64 位十六进制 sha256;环境不支持 crypto.subtle 时抛错由调用方兜底 + */ +async function sha256Hex(text: string): Promise { + const subtle = globalThis.crypto?.subtle; + if (!subtle) { + // 非安全上下文(如 http 非 localhost)无 subtle;抛错由 GamePlayer 走 demo 兜底 + throw new Error('crypto.subtle 不可用(需安全上下文 https/localhost)'); + } + const bytes = new TextEncoder().encode(text); + const digest = await subtle.digest('SHA-256', bytes); + // ArrayBuffer → hex + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * GAP-2:按 manifestUrl 拉取并校验游戏包(manifest.json → GamePackage #4)。 + * + * 链路(T-RT-15 完整性校验): + * fetch(manifestUrl) → 取原始文本 → 解析 GamePackage → 对原始文本算 sha256, + * 与运行包清单的 checksum 比对,一致才返回真包,否则抛错(由 GamePlayer 走 demo 兜底 + emit error)。 + * + * 安全:仅 GET 拉取数据(无脚本执行);返回的 GamePackage 仅作为数据注入 iframe(inject 内 JSON.stringify), + * 不会被当作可执行内容求值。manifestUrl 的同源/CORS 由 runtime 下发 sandbox.allowOrigins + OSS CORS 约束保障。 + * + * @param manifestUrl manifest.json 直取 URL(runtime 清单回填) + * @param expectedChecksum 期望的整包 sha256(hex,来自运行包清单 checksum) + * @returns 校验通过的 GamePackage + * @throws 网络失败 / 非 JSON / 结构非法 / checksum 不匹配 / 无 crypto.subtle + */ +export async function fetchAndVerifyManifest( + manifestUrl: string, + expectedChecksum?: string, +): Promise { + // 1) 拉取原始文本(保留原始字节序,确保 checksum 可复现) + const resp = await fetch(manifestUrl, { method: 'GET', credentials: 'omit' }); + if (!resp.ok) { + throw new Error(`取 manifest 失败:HTTP ${resp.status}`); + } + const raw = await resp.text(); + + // 2) checksum 校验(清单提供 checksum 时才强校验;缺省则跳过仅警告,避免误杀 mock 链路) + if (expectedChecksum) { + const actual = await sha256Hex(raw); + if (actual.toLowerCase() !== expectedChecksum.toLowerCase()) { + throw new Error('manifest 完整性校验失败(checksum 不匹配)'); + } + } + + // 3) 解析为 GamePackage(#4)。结构异常按失败处理。 + let pkg: GamePackage; + try { + pkg = JSON.parse(raw) as GamePackage; + } catch { + throw new Error('manifest 非合法 JSON'); + } + if (!pkg || typeof pkg !== 'object' || !pkg.manifest || !Array.isArray(pkg.assets)) { + throw new Error('manifest 结构非法(缺 manifest/assets)'); + } + return pkg; +} + /** * 构造一个 demo 游戏包(契约 #4 形状),用于骨架阶段本地试玩。 * 真实链路中 GamePlayer 会用 runtime.getPackage 返回的清单 + 真实 GamePackage。 diff --git a/game-studio/src/mock/feed.ts b/game-studio/src/mock/feed.ts index 46970a43..93a6c46f 100644 --- a/game-studio/src/mock/feed.ts +++ b/game-studio/src/mock/feed.ts @@ -176,6 +176,9 @@ function shareHandler(req: MockRequest): ShareMeta { const title = TITLES[(gameId - 1) % TITLES.length] return { gameId, + // GAP-3:分享落地页能直接拿到「即玩版本」。mock 统一回 DEMO_VERSION_ID(已发布态, + // 与 feed 卡片/runtime 取包同源);真实后端仅在 isGamePublished 时回填,未发布缺省。 + versionId: DEMO_VERSION_ID, shareUrl: `https://wanxiang.ai/share/${gameId}?utm_source=${encodeURIComponent(channel)}`, ogTitle: `来玩《${title}》`, ogImage: cover(gameId, title), diff --git a/game-studio/src/mock/project.ts b/game-studio/src/mock/project.ts index 3517c4c3..818d6ee1 100644 --- a/game-studio/src/mock/project.ts +++ b/game-studio/src/mock/project.ts @@ -5,15 +5,25 @@ * POST /app-api/project → Long(新建项目 ID) * GET /app-api/project/:id → ProjectRespVO * PUT /app-api/project/:id/draft → boolean - * POST /app-api/project/:id/publish → boolean + * POST /app-api/project/:id/publish → ProjectPublishRespVO{admitted, gates[]}(GAP-1 结构化门禁聚合) * - * 注:publish 响应在契约中仅为 CommonResultBoolean(无结构化门禁清单字段), - * 故 mock 严格返回 boolean;“发布门禁失败项清单”能力为契约缺口,已在返回 contractGaps 上报, - * 不在此私造响应字段。mock 以 launchZoneId 是否传入做最小门禁演示(缺失→业务错误码)。 + * 注(GAP-1,已落地):publish 响应已由 boolean 升级为结构化信封 ProjectPublishRespVO{admitted, gates[]}, + * 与后端 project.yaml 同批迁移、无并存期。mock 据此返回 {admitted, gates}: + * - 归属/草稿态/必填缺失 = 安全前置,抛业务错误码(对齐后端「失败抛异常,不进 gates」); + * - 适龄提示(age) = 业务可恢复门禁,未声明则 admitted=false 且该 gate.pass=false(不抛异常)。 + * gate.key 与后端一致(status/title/cover/version/age/zone/compliance);compliance 由 Phase C 后端注入,mock 不私造。 */ import type { MockRoute, MockRequest } from './shared' import { MockError, nowISO, seededInt } from './shared' -import type { Project, ProjectCreateReq, ProjectDraftReq, ProjectPublishReq, ProjectStatus } from '../api/types' +import type { + GateResult, + Project, + ProjectCreateReq, + ProjectDraftReq, + ProjectPublishReq, + ProjectPublishResp, + ProjectStatus, +} from '../api/types' /** demo 版本 ID(与全链路对齐) */ const DEMO_VERSION_ID = 9001 @@ -122,23 +132,50 @@ function draftHandler(req: MockRequest): boolean { return true } -/** POST /app-api/project/:id/publish */ -function publishHandler(req: MockRequest): boolean { +/** + * POST /app-api/project/:id/publish(GAP-1:返回结构化门禁聚合 ProjectPublishResp) + * + * 安全前置(抛业务错误码,不进 gates,对齐后端「失败抛异常」): + * - 项目不存在 / 非草稿态 / 必填 versionId+launchZoneId 缺失。 + * 业务可恢复门禁(收集为 gates,不抛异常,由 admitted 表达整体放行/拦截): + * - age 适龄提示:未声明 ageRating 则该门禁 pass=false、admitted=false、不改状态机。 + * 全部门禁通过 → 置 status=1 审核中并 admitted=true。 + */ +function publishHandler(req: MockRequest): ProjectPublishResp { ensureSeed() const id = Number(req.params.id) const p = projects.get(id) if (!p) throw new MockError(1100002001, '项目不存在') - // 贴契约:仅 status=0 可提交发布 + // 安全前置:仅 status=0 可提交发布(抛异常,不进 gates) if (p.status !== 0) throw new MockError(1100003002, '仅草稿可提交发布') const body = (req.body ?? {}) as Partial - // 契约 ProjectPublishReqVO required: versionId + launchZoneId + // 安全前置:契约 ProjectPublishReqVO required: versionId + launchZoneId(缺失抛异常) if (!body.versionId || !body.launchZoneId) { - // 以缺失必填模拟“发布前检查不通过”(契约 publish 响应仅 boolean,无清单字段) throw new MockError(1100004001, '发布前检查未通过:缺少版本或发布专区') } - // 通过 → 置 status=1 审核中(贴契约语义) - projects.set(id, { ...p, status: 1, launchZoneId: body.launchZoneId }) - return true + + // 业务门禁聚合(key 与后端一致;通过项也回显,供前端逐条展示) + const gates: GateResult[] = [] + // age 适龄提示:未声明则拦截(对齐后端 ProjectServiceImpl.submitPublish 的 age gate) + if (p.ageRating) { + gates.push({ key: 'age', label: '适龄提示', pass: true }) + } else { + gates.push({ + key: 'age', + label: '适龄提示', + pass: false, + code: '1100000003', + message: '发布前必须填写适龄提示', + }) + } + // compliance 合规扫描:Phase C 由后端注入,mock 不私造该 gate(缺口保持,不臆造响应)。 + + const admitted = gates.every((g) => g.pass) + // 仅 admitted=true 才改状态机(无副作用纪律:拦截则不改 status) + if (admitted) { + projects.set(id, { ...p, status: 1, launchZoneId: body.launchZoneId }) + } + return { admitted, gates } } /** 导出本模块路由表(具体的 /draft /publish 须排在 /:id 之前) */ diff --git a/game-studio/src/mock/runtime.ts b/game-studio/src/mock/runtime.ts index cac5a463..ee2b1545 100644 --- a/game-studio/src/mock/runtime.ts +++ b/game-studio/src/mock/runtime.ts @@ -36,6 +36,13 @@ function packageHandler(req: MockRequest): RuntimePackage { // 模板:clicker(与 demo 点击计数玩法一致) templateId: 'clicker', packageUrl: `/games/${gameId}/versions/${versionId}/manifest.json`, + // GAP-2:mock 刻意「不」回填 manifestUrl。 + // 宿主 GamePlayer.resolvePackage 对「无 manifestUrl」走 demo 兜底(assets 为空的 clicker), + // 保证本地 mock 链路无需真实 OSS 即可跑通;而 manifestUrl 的 fetch+sha256 校验路径在 + // 隔离 staging 联调(runtime 回填真实 manifestUrl + OSS CORS)时验证。 + // 若在此填一个 mock 中间件(仅拦 /app-api、/admin-api)无法服务的 /games/... 路径, + // 只会触发取包失败兜底,污染 error 事件,故不填。详见返回 openIssues。 + // manifestUrl: undefined, // 入口约定为 'demo':A2 Runtime 据此加载内置 demo 游戏(非真实文件路径,链路约定值) entry: 'demo', runtimeVersion: '1.0.0', diff --git a/game-studio/src/store/project.ts b/game-studio/src/store/project.ts index c4ce55e3..e8e3c87e 100644 --- a/game-studio/src/store/project.ts +++ b/game-studio/src/store/project.ts @@ -6,7 +6,13 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import * as projectApi from '../api/project' -import type { Project, ProjectCreateReq, ProjectDraftReq, ProjectPublishReq } from '../api/types' +import type { + Project, + ProjectCreateReq, + ProjectDraftReq, + ProjectPublishReq, + ProjectPublishResp, +} from '../api/types' export const useProjectStore = defineStore('project', () => { /** 我的项目列表 */ @@ -67,11 +73,14 @@ export const useProjectStore = defineStore('project', () => { } /** - * 提交发布(发布前 7 门禁 → 审核)。 + * 提交发布(发布前门禁聚合 → 审核,GAP-1)。 + * 透传后端结构化门禁结果 {admitted, gates};admitted=true 时后端已置 status=1 审核中, + * 由调用方(Publish.vue)据 admitted 决定成功/拦截态并逐条渲染 gates。 * @param id 项目 ID * @param dto versionId + launchZoneId + * @returns 发布门禁聚合结果 */ - async function publish(id: number, dto: ProjectPublishReq): Promise { + async function publish(id: number, dto: ProjectPublishReq): Promise { return projectApi.publish(id, dto) } diff --git a/game-studio/src/views/project/Publish.vue b/game-studio/src/views/project/Publish.vue index 1f34893b..3a85bb6f 100644 --- a/game-studio/src/views/project/Publish.vue +++ b/game-studio/src/views/project/Publish.vue @@ -2,18 +2,18 @@ /** * Publish.vue —— 发布门禁页(创作区 /project/:id/publish)。 * - * 闭环职责(spec §3.2 / B3 / §6 边缘失败): + * 闭环职责(spec §3.2 / B3 / §6 边缘失败 / GAP-1): * - 进入即加载项目详情(loadDetail)+ 专区列表(feedApi.getZones),用于发布去向选择。 - * - 展示发布前 7 门禁“预检清单”(computeGates,纯客户端,见 publish-gates.ts 顶部契约说明)。 - * - 提交发布 → projectStore.publish(id, {versionId, launchZoneId}) → project.publish(POST /publish)。 - * - 成功(服务端返回 boolean=true)→ 进入“已提交审核”成功态提示。 - * - 失败(服务端错误码/校验未过)→ 列出失败项(服务端 msg 为权威失败原因)+ 可“返回修改”。 + * - 展示发布前客户端「预检清单」(computeGates,纯客户端,仅辅助引导,非权威)。 + * - 提交发布 → projectStore.publish(id, {versionId, launchZoneId}) → 返回结构化 {admitted, gates}。 + * - admitted=true → 进入「已提交审核」成功态。 + * - admitted=false → 展示服务端逐项门禁结果(失败项高亮 label+message),创作者据此修复后重提。 + * - 安全前置失败(后端抛错误码,如非草稿态/缺必填)→ catch 走失败态,msg 为权威原因。 * - * ⚠ 契约纪律(最高优先级): - * - 契约 publish 响应仅 CommonResultBoolean,**无结构化 7 门禁逐项结果字段**。 - * - 本页“7 门禁清单”是客户端预检(资料完整性自检),用于引导创作者; - * **权威判定以服务端 boolean / 错误码为准**,绝不据 mock 私造契约外的门禁响应结构。 - * - 该缺口已在返回 contractGaps 上报,待主 agent 决策(如需后端补结构化门禁响应)。 + * 契约纪律(GAP-1 已落地,最高优先级): + * - publish 响应已升级为结构化 ProjectPublishResp{admitted, gates[]},**服务端 gates 为权威判定**。 + * - 客户端 computeGates 预检退为「辅助提示」:仅在提交前引导创作者补资料,不作准入裁决依据。 + * - 渲染服务端结果时严格用 gates 的 key/label/pass/message,不私造契约外字段。 */ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' @@ -22,7 +22,7 @@ import { useProjectStore } from '../../store/project' import { feedApi } from '../../api' import { LoadingBar, AppButton, EmptyState } from '../../components' import { computeGates } from './publish-gates' -import type { Zone } from '../../api/types' +import type { GateResult, Zone } from '../../api/types' /** 路由 props:true 注入 :id */ const props = defineProps<{ id: string }>() @@ -47,22 +47,29 @@ const launchZoneId = ref(undefined) /** * 提交结果态: - * - idle:尚未提交,展示预检清单 + 提交按钮。 - * - success:服务端受理(boolean=true),展示“已进入审核”成功提示。 - * - failed:服务端拒绝(错误码),展示失败原因 + 返回修改。 + * - idle:尚未提交,展示客户端预检清单 + 提交按钮。 + * - success:服务端准入(admitted=true),展示“已进入审核”成功提示。 + * - failed:服务端门禁拦截(admitted=false)或安全前置抛错,展示服务端逐项门禁 + 返回修改。 */ const phase = ref<'idle' | 'success' | 'failed'>('idle') -/** 失败原因文案(来自服务端 msg,权威失败原因) */ +/** 失败原因文案(安全前置抛错时来自服务端 msg;门禁拦截时为汇总提示) */ const failReason = ref('') +/** + * 服务端返回的逐项门禁结果(GAP-1 权威判定)。 + * admitted=false 时据此高亮失败项(label + message);为空表示走的是安全前置抛错路径。 + */ +const serverGates = ref([]) +/** 服务端门禁中的失败项(高亮展示用) */ +const serverFailedGates = computed(() => serverGates.value.filter((g) => !g.pass)) /** 当前项目(store.current) */ const current = computed(() => projectStore.current) -/** 7 门禁预检结果(基于当前项目 + 已选专区计算) */ +/** 客户端预检结果(辅助引导,非权威;权威以服务端 gates 为准,见 publish-gates.ts 头注) */ const gates = computed(() => computeGates(current.value ?? null, launchZoneId.value)) -/** 是否全部预检通过(仅用于按钮态/前置提醒,非权威) */ +/** 是否全部客户端预检通过(仅用于提交前软提醒,非准入裁决) */ const allPass = computed(() => gates.value.every((g) => g.pass)) -/** 预检未通过项(提交前提示用) */ +/** 客户端预检未通过项(提交前提示用) */ const failedGates = computed(() => gates.value.filter((g) => !g.pass)) /** 加载项目详情 */ @@ -96,11 +103,14 @@ function selectZone(id: number) { } /** - * 提交发布。 + * 提交发布(GAP-1:消费结构化 {admitted, gates})。 * 流程: - * 1) 前置:若客户端预检有失败项,先提示(仍允许提交由服务端最终裁决,避免前端越权拦死)。 - * 但 versionId / launchZoneId 是契约 required,缺失则直接前端拦截(否则必然 400)。 - * 2) 调 project.publish;成功 → success 态;服务端业务错误 → failed 态(msg 为权威失败原因)。 + * 1) 前置:客户端预检有失败项仅软提醒(不拦死,准入以服务端为权威); + * 但 versionId / launchZoneId 是契约 required,缺失则前端直接拦截(否则必然 400)。 + * 2) 调 project.publish: + * - admitted=true → success 态(后端已置 status=1 审核中)。 + * - admitted=false → failed 态,serverGates 记录服务端逐项门禁,高亮失败项。 + * - 安全前置抛错(catch)→ failed 态,msg 为权威原因,serverGates 置空。 */ async function onSubmit() { if (submitting.value) return @@ -117,30 +127,34 @@ async function onSubmit() { showToast({ message: '请选择发布去向专区', type: 'fail' }) return } - // 其余软门禁未过:仅提醒,不拦死(服务端为权威) + // 客户端软门禁未过:仅提醒,不拦死(服务端 gates 为权威准入) if (!allPass.value) { - showToast({ message: '部分发布资料未完善,仍可提交由审核裁决', type: 'text' }) + showToast({ message: '部分发布资料未完善,仍可提交由服务端门禁裁决', type: 'text' }) } submitting.value = true try { // 严格贴契约:{ versionId, launchZoneId } - const ok = await projectStore.publish(projectId.value, { + const resp = await projectStore.publish(projectId.value, { versionId, launchZoneId: launchZoneId.value, }) - if (ok) { - // 服务端受理 → 进入审核态成功提示 + // 服务端门禁结果为权威:记录逐项 gates 供失败态高亮 + serverGates.value = resp.gates ?? [] + if (resp.admitted) { + // 全部门禁通过 → 进入审核态成功提示 phase.value = 'success' // 刷新详情,使状态机更新为 1 审核中(供返回列表时显示一致) await projectStore.loadDetail(projectId.value).catch(() => {}) } else { - // 理论上 code=0 即 true;ok=false 视作未受理 + // 门禁拦截:未改状态机,列出服务端失败项供创作者修复后重提 phase.value = 'failed' - failReason.value = '发布未被受理,请稍后重试' + const firstFail = serverGates.value.find((g) => !g.pass) + failReason.value = firstFail?.message || '发布前门禁未通过,请按下方提示修复后重提' } } catch (err) { - // 服务端业务错误码:msg 为权威失败原因(request.ts 已 Toast,这里落到失败态展示) + // 安全前置抛错(归属/草稿态/缺必填):msg 为权威失败原因(request.ts 已 Toast) + serverGates.value = [] phase.value = 'failed' failReason.value = (err as Error)?.message || '发布前检查未通过' } finally { @@ -162,6 +176,7 @@ function goList() { function retry() { phase.value = 'idle' failReason.value = '' + serverGates.value = [] } onMounted(() => { @@ -205,19 +220,28 @@ onMounted(() => { - +
!
-

发布前检查未通过

- +

发布前门禁未通过

+

{{ failReason }}

- -
    + + +
      +
    • + + {{ g.label || g.key }}:{{ g.message || '未通过' }} +
    • +
    + +
    • {{ g.label }}:{{ g.hint }}
    +
    返回修改 重新检查 @@ -226,14 +250,14 @@ onMounted(() => {
    - +

    - 以下为发布前自检清单,最终是否通过以平台审核为准。 + 以下为发布前自检清单(辅助提示);最终准入以提交后服务端门禁结果为准。

    - +
    -

    发布前检查(7 项)

    +

    发布前自检(辅助)

    • diff --git a/game-studio/src/views/project/publish-gates.ts b/game-studio/src/views/project/publish-gates.ts index ee8f883f..dc9ed2b1 100644 --- a/game-studio/src/views/project/publish-gates.ts +++ b/game-studio/src/views/project/publish-gates.ts @@ -1,19 +1,16 @@ /** - * 发布前门禁(客户端预检清单)—— B3 发布门禁页用。 + * 发布前门禁(客户端预检清单,软提示)—— B3 发布门禁页用。 * - * ⚠ 契约边界(最高优先级,务必理解): - * 契约 project.yaml 的 POST /app-api/project/{id}/publish 响应是 **CommonResultBoolean**, - * 仅返回 boolean(成功=进审核),**并不返回结构化的“7 门禁逐项校验结果”字段**。 - * spec §3.2 期望发布页“展示 7 门禁校验结果 / 失败列出具体失败项”,与契约存在缺口(已在 contractGaps 上报)。 + * 契约现状(GAP-1 已落地): + * POST /app-api/project/{id}/publish 响应已升级为结构化 ProjectPublishRespVO{admitted, gates[]}, + * **服务端 gates 为发布准入的权威判定**(含 age 适龄、compliance 合规扫描等逐项结果)。 * - * 因此本文件提供的是 **纯客户端预检(pre-check)**: - * - 仅基于页面已掌握的项目字段(标题/封面/简介/版本/适龄/专区等)做“发布前自检”, - * 给创作者“发布需要满足什么”的可读引导(满足 spec 的 UX 意图)。 - * - 它**不是**权威判定:最终是否过门禁、是否进审核,以服务端 publish 的 boolean / 错误码为准。 + * 因此本文件退为 **辅助软提示(pre-check)**: + * - 仅基于页面已掌握的项目字段(标题/封面/简介/版本/适龄/专区等)做发布前自检, + * 在提交前给创作者“发布大致需要满足什么”的可读引导,降低被服务端门禁拦截的概率。 + * - 它**不是**权威判定:最终是否准入、是否进审核,以服务端 publish 返回的 gates / admitted 为准。 + * - Publish.vue 失败态优先渲染服务端 gates;仅服务端走安全前置抛错(无 gates)时才回落到本预检项。 * - 严禁据此发明/伪造契约外的 publish 响应结构。 - * - * 设计取舍:把“7 门禁”落为 7 条围绕发布资料完整性与合规可读项的自检, - * 与契约 publish 描述“发布前检查清单 7 门禁”在语义上对齐,但实现为前端可计算的资料校验,不臆造后端门禁字段。 */ import type { Project } from '../../api/types' diff --git a/game-studio/src/views/share/Share.vue b/game-studio/src/views/share/Share.vue index fec3a0a9..46421b52 100644 --- a/game-studio/src/views/share/Share.vue +++ b/game-studio/src/views/share/Share.vue @@ -5,12 +5,12 @@ * 职责(spec §3.1 / AC3): * 1. 拉 OG 元数据:feed.getShareMeta(gameId, channel)(feed.yaml /feed/share/{gameId}) * → 展示封面(ogImage)/标题(ogTitle)/简介(ogDescription)。 - * 2.「立即玩」→ 跳 /play/:gameId 进入试玩(玩家闭环回流)。 + * 2.「立即玩」→ 跳 /play/:gameId/:versionId 进入试玩(玩家闭环回流)。 * 3. 渠道归因:channel 从 URL query 读(utm_source 等),透传给 getShareMeta 回显并埋点。 * - * 注意(契约观察):feed.yaml ShareMetaRespVO 不含 versionId,故落地页无法从分享契约直接拿到 - * 「即玩版本」。本页据此跳 /play/:gameId(不带 versionId);试玩页/宿主在 mock 阶段以 demo 包兜底, - * 真实联调需由 play 路由侧或 feed 契约补 versionId。此为契约缺口,已上报,不在本页私造字段。 + * GAP-3 已落地:feed.yaml ShareMetaRespVO 新增 versionId(已发布游戏回填,未发布缺省)。 + * 本页「立即玩」携带 meta.versionId 跳 /play/:gameId/:versionId(缺省兜底空串,由可选路由参吸收), + * 使宿主可直接按版本取包,不再单靠 demo 兜底。 */ import { ref, onMounted } from 'vue' import { useRouter, useRoute } from 'vue-router' @@ -71,14 +71,15 @@ onMounted(() => { /** * 立即玩:跳试玩页(玩家闭环回流)。 - * 受限于 ShareMeta 无 versionId,仅带 gameId(mock 阶段宿主 demo 兜底)。 + * GAP-3:携带 ShareMeta.versionId 跳 /play/:gameId/:versionId(已发布游戏由后端回填); + * 未发布或缺省时 versionId 兜底空串,由可选路由参 /play/:gameId/:versionId? 吸收,宿主再退回兜底。 */ function onPlay(): void { const gid = numGameId() if (!gid) return track('share', { action: 'click_play', channel: meta.value?.channel }, { gameId: gid }) - // /play/:gameId/:versionId? —— 此处 versionId 缺省(契约缺口,见文件头说明) - router.push(`/play/${gid}`) + const vid = meta.value?.versionId ?? '' + router.push(`/play/${gid}/${vid}`) } /** 返回游戏流(落地页二级入口) */