feat(wave3-phaseB): compliance/studio 模块 + contractGaps(后端全绿)

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 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-09 02:15:20 +00:00
parent 096020fa0a
commit b70e90b775
95 changed files with 5021 additions and 97 deletions

View File

@ -0,0 +1,218 @@
openapi: 3.0.3
# 契约 #1 API | 模块compliancegame-module-complianceWave3 合规域「锁风门 Gate」| ownerWave3 compliance 子 agent 主笔,全员 review
# 职责(架构 Doc B + D2 范围铁律):只做 Gate——发布前合规扫描聚合风格/IP 原子→verdict+ 内容分级 + 封禁台账。
# 【明确不做】审核状态机:审核状态机权威归 projectgame_review_recordcompliance 绝不重写D2
# seamR1compliance 只交付 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
# IPD5ip 不独立建模块,仅在 compliance 内留 IpComplianceSeam 扩展点(默认 pass 桩Wave4 接 ip 只换实现。
# 端:/rpc-apiproject.publish 同进程 Feign 注入)/ /admin-api管理端 game-adminRBAC 封禁)/ /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 不在本模块登记R1block→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-apiproject.publish 同进程注入,唯一 seamR1
# ===========================================================================
/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封禁台账RBACR3 状态分支)
# ===========================================================================
/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:
# ===== 锁风门 GateRPC seamR1 唯一形态)=====
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: # = 草案 ComplianceGateRespDTOR1 命名对齐
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 可空)' }
# ===== 封禁adminR3 状态分支)=====
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 }

View File

@ -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: '当前生效版本 IDGAP-3与 FeedItem.versionId 同源 project.currentVersionId仅已发布游戏回填未发布或无生效版本留空供分享落地页直达即玩加载' }
# ---- 精选池运营 ----
FeaturedReqVO:

View File

@ -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 的 CORSAllowedOrigin=宿主 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: 'GamePackagemanifestOSS/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 应与本白名单一致(含宿主 originAllowedMethod=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:

View File

@ -0,0 +1,186 @@
openapi: 3.0.3
# 契约 #1 API | 模块studiogame-module-studioWave3 创作主链路最小版)| ownerstudio 路
# 职责(架构 Doc B §71-76有状态创作工作台后端。编排「建草稿(委托 project createProject)→一句话生成(委托 aigc)
# →进度轮询→重生成→模板应用」。不跑 LLM委托 aigc、不持久化项目版本交 project、不做锁风裁决调 compliance
# 与 game-studio 前端仓区分:本契约是后端 studio 模块(错误码段 112game-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 轮询非 MQD4
# /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-contractsstudio 是调用方,依赖以下对方 -api seam本契约不对外暴露 Feign
# - project-api ProjectApi.createProject(creatorUserId, title, templateId) → gameId写类 RPC显式 creatorUserId归属校验在 project service
# - aigc-api AigcApi.submitGenerate / getTask / retryTask / getTemplateListWave3 新增 seam纯暴露层委托 AigcTaskService
# attachmentsR7StudioGenerateReqVO.attachments 本波仅持久化studio 侧落库),不透传 aigcaigc reqDTO 无 attachments 字段P1 再透传。
# 狠切留 seam 不实现P1rig/对白树/可视化批量资产图仅槽位表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-03MVP 骨架返回空列表占位,待模板注册表接入。
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 轮询。同步直写无 MQD4
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: '创作任务链 IDtaskChainId' }
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: '附件上下文 JSONP-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: '附件上下文 JSONP-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: '示例 PromptP-CRT-11', example: '做一个躲避陨石的小游戏' }
StudioSessionRespVO:
type: object
properties:
id: { type: integer, format: int64, description: '创作会话 IDsessionId', example: 1024 }
gameId: { type: integer, format: int64, description: '移交后的游戏 IDproject.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: '创作任务链 IDtaskChainId轮询用', 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: '关联游戏 IDproject.game_project.id', example: 2048 }
versionId: { type: integer, format: int64, description: '产物版本 IDproject.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: '若为重生成,指向原任务链 IDP-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 }

View File

@ -0,0 +1,92 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块studiogame-module-studioWave3 创作主链路最小版)| ownerstudio 路
-- 文件V8.0.0__create_game_studio.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移 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
-- 边界纪律:
-- - 项目/版本权威归 projectgame_project / game_versionstudio 仅持 game_id/version_id 引用,不在本模块建版本表。
-- - 生成任务权威归 aigcgame_aigc_taskstudio 任务链仅持 aigc_task_id 引用 + 自身链态,不在本模块重建生成状态机。
-- - attachments 本波仅持久化R7不透传 aigcaigc 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草稿移交 projectP-TPL-03
-- attachmentsJSON 持久化(本波仅持久化不透传 aigcR7
-- 状态机 status0 草稿 → 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 '移交后的游戏 IDproject.game_project.idcreateDraft 调 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-04JSON 数组本波仅持久化P1 透传 aigc',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '会话状态机0草稿 1生成中 2完成 3失败StudioSessionStatusEnumService 校验流转)',
`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 '租户 IDYudao 多租户兼容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_idgetTask 轮询透传 aigc 进度/状态映射链态。
-- 引用边界aigc_task_id 引用 aigc.game_aigc_task.id生成权威归 aigcversion_id 引用 project.game_version.id
-- aigc succeeded 后回填,版本权威归 project本表只持引用 + 自身链态,不重建生成状态机。
-- 重生成血缘retry_of 指向原任务链 idP-CRT-09regenerate 新建一条 retry_of=原 id 的链。
-- 链态 status0 初始 → 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 '创作者用户 IDDataPermission 边界,归属校验用)',
`aigc_task_id` BIGINT NULL DEFAULT NULL COMMENT '委托的 aigc 生成任务 ID= aigc.game_aigc_task.idgenerate 后回填,轮询据此查 aigc',
`game_id` BIGINT NULL DEFAULT NULL COMMENT '关联游戏 IDproject.game_project.id透传 aigc 用)',
`version_id` BIGINT NULL DEFAULT NULL COMMENT '产物版本 IDproject.game_version.idaigc 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 '若为重生成,指向原任务链 IDP-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 '租户 IDYudao 多租户兼容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-02MVP 仅落槽位 seam不六路并发
-- 用途单步生成产物的资产槽位记录asset_type 槽位 + asset_url + 槽位状态)。
-- 狠切R7/草稿 scopeMVP 只建槽位表占位 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/iconMVP 槽位占位)',
`asset_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '资产 URLOSS/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 '租户 IDYudao 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
KEY `idx_session_type` (`session_id`, `asset_type`) COMMENT '按会话+槽位类型查资产'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作资产槽位表(单步出整包槽位 seamMVP 不六路并发)';

View File

@ -0,0 +1,84 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块compliancegame-module-complianceWave3 合规域「锁风门 Gate」| ownerWave3 compliance 子 agent
-- 文件V9.0.0__create_game_compliance.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移 V9.0.1 DROP TABLE
-- 内容compliance 模块三张表 —— 锁风门评估台账 game_compliance_gate_result + 内容分级 game_content_rating + 封禁台账 game_user_ban
-- 职责(架构 Doc B + D2 范围铁律):只做 Gate聚合风格/IP 原子→verdict + 内容分级 + 封禁台账);
-- 【明确不建】审核状态机表 / audit_record —— 审核状态机权威归 projectgame_review_recordcompliance 绝不重写。
-- 锁风门 seamR1project.submitPublish 同进程调 ComplianceGateApi.evaluate → 聚合裁决落 game_compliance_gate_result + 分级落 game_content_rating
-- → 返回 verdict 供 project 据此置 admittedblock→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 时落聚合裁决 + 各原子明细 JSONadmin 复核与合规溯源据此查
-- 写口径非幂等每次评估各落一条审计记录不去重verdict 为聚合结果 max(各原子)
-- -----------------------------------------------------------------------------
CREATE TABLE `game_compliance_gate_result` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '锁风门评估记录 ID',
`game_id` BIGINT NOT NULL COMMENT '评估的游戏 IDproject.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 '租户 IDYudao 多租户兼容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 '游戏 IDproject.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 '租户 IDYudao 多租户兼容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)可再次封禁
-- 状态机 status1 生效 → 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 '租户 IDYudao 多租户兼容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 状态分支)';

View File

@ -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 生成 APIaigc studio seamWave3 新增
*
* 用途studio 创作主链路一句话生成/进度轮询/重生成/模板浏览委托 aigc 既有 {@code AigcTaskService}
* 设计纪律 seam 为纯暴露层@RestController @Primary 委托 service不改 service删除后 aigc 既有单测仍绿
* 跨模块只依赖 aigc -apicn.wanxiang.game.module.aigc.api/.dto禁止 studio 依赖 aigc -server避免编译耦合
*
* MVP 单体同进程内由 {@code AigcApiImpl}(@RestController @Primary) 就地解析仿 yudao DictDataApi
* 拆微服务后按 {@link ApiConstants#NAME} 走真实 Feign
*
* 写类 RPCsubmitGenerate/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<List<AigcTemplateDTO>> 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/traceIdCommonResult 包裹
*/
@PostMapping(PREFIX + "/submit-generate")
@Operation(summary = "提交生成任务studio 委托,异步入队)")
CommonResult<AigcGenerateRespDTO> 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/failureReasonCommonResult 包裹
*/
@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<AigcTaskRespDTO> 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/traceIdCommonResult 包裹
*/
@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<AigcGenerateRespDTO> retryTask(@RequestParam("taskId") Long taskId,
@RequestParam("userId") Long userId);
}

View File

@ -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;
/**
* 关联游戏 IDproject.game_project.idstudio 草稿移交后回填产物经此落 game_version
*/
private Long gameId;
/**
* 关联版本 IDproject.game_version.id指定则产物写入该版本为空时由生成流程新建版本
*/
private Long versionId;
}

View File

@ -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= 对外 taskIdstudio 回填 taskchain.aigc_task_id 后轮询用
*/
private Long taskId;
/**
* 初始状态0 queued对齐 AigcTaskStatusEnum
*/
private Integer status;
/**
* 贯穿全链路 traceId透传给 studio/日志关联
*/
private String traceId;
}

View File

@ -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_idfailed(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-100studio 透传给前端进度条
*/
private Integer progress;
/**
* 产物落地的版本 IDproject.game_version.idstatus=2 succeeded 后非空studio 据此回填任务链 version_id
*/
private Long versionId;
/**
* 失败原因分类status=3/4 时非空
*/
private String failureReason;
/**
* 贯穿全链路 traceId
*/
private String traceId;
}

View File

@ -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 -apicn.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;
/**
* 示例 PromptP-CRT-11 示例 Prompt
*/
private String examplePrompt;
}

View File

@ -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
* - NAMEFeign 目标服务名MVP 为单体spring.application.name=yudao-server所有模块编入一个 JAR
* 同进程内调用由 {@code AigcApiImpl}(@RestController @Primary) 就地解析 yudao DictDataApi 同构
* 拆微服务后该值即对应 aigc 服务的 spring.application.name
* - PREFIXRPC 接口前缀 /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";
}

View File

@ -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
* 纯暴露层仅做 DTOVO/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<List<AigcTemplateDTO>> getTemplateList() {
// 透传 aigc 模板注册表MVP 骨架返回空列表占位
List<TemplateRespVO> list = aigcTaskService.getTemplateList();
List<AigcTemplateDTO> 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<AigcGenerateRespDTO> submitGenerate(AigcGenerateReqDTO req) {
// DTO 既有 AigcGenerateReqVOattachments 不在 req本波不透传 aigcR7userId 取显式入参
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<AigcTaskRespDTO> 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<AigcGenerateRespDTO> retryTask(Long taskId, Long userId) {
// 委托既有 retryTask含状态机/归属校验基于原任务新建一条 retry_of 任务
AigcTaskDO retry = aigcTaskService.retryTask(taskId, userId);
return success(toGenerateRespDTO(retry));
}
/**
* 任务 DO 提交/重试返回 DTOtaskId/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;
}
}

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-compliance</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>game-module-compliance-api</artifactId>
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<description>
compliance 模块 API错误码常量段 109、跨模块 DTO/FeignComplianceGateApi 暴露给 project.publish 注入锁风门 Gate契约先行先定本层。
</description>
<dependencies>
<!-- 通用基础ErrorCode / CommonResult / PageResult 等 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-common</artifactId>
</dependency>
<!-- 接口文档Swagger 模型注解(仅编译期需要) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<scope>provided</scope>
</dependency>
<!-- 参数校验注解 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<optional>true</optional>
</dependency>
<!-- RPC 远程调用ComplianceGateApi 为 @FeignClient供 project.publish 跨模块同步调用) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@ -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 APIcompliance project.publish seam
*
* 对应契约 compliance.yamlcompliance 通过本 -api 暴露给 project.publish 注入的唯一 seamR1
* 用途project.submitPublish 在发布前同步调 evaluate据返回 verdict 映射为 key=compliance GateResultVO
* block admitted=false 不抛异常pass/review REVIEWING注入逻辑由主 agent GAP-1 串行写compliance 只交付本 seam
* 跨模块只依赖 compliance -apicn.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<GateVerdict> evaluate(@RequestBody ComplianceGateReqDTO reqDTO);
}

View File

@ -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;
}

View File

@ -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_resulttitle/summary/ageRating/promptHash 供风格原子参考MVP 桩默认 pass
*
* @author 造梦AI
*/
@Data
public class ComplianceGateReqDTO {
/**
* 游戏项目 IDgame_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;
}

View File

@ -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
*
* = 草案 ComplianceGateRespDTOR1 命名对齐为 GateVerdict
* verdict 为聚合结果 = max(各原子)block > review > passdetail 为各原子明细rating 为本次评估落定的内容分级
* project.submitPublish verdict 映射为 key=compliance GateResultVOR1 唯一注入点
*
* @author 造梦AI
*/
@Data
public class GateVerdict {
/**
* 聚合裁决pass/review/block ComplianceVerdictEnum 编码一致
*/
private String verdict;
/**
* 内容分级all/8+/12+/16+本次评估落定可空
*/
private String rating;
/**
* 各合规原子明细风格/图像/IP 逐条裁决
*/
private List<ComplianceGateItemDTO> detail;
}

View File

@ -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 黄金模板
* - NAMEFeign 目标服务名MVP 为单体spring.application.name=yudao-server所有模块编入一个 JAR
* 同进程内调用由本模块 {@code ComplianceGateApiImpl}(@RestController @Primary) 就地解析 yudao DictDataApi 同构
* 拆分微服务后该值即对应 compliance 服务的 spring.application.name
* - PREFIXRPC 接口前缀 /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";
}

View File

@ -0,0 +1,37 @@
package cn.wanxiang.game.module.compliance.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
/**
* 封禁状态机枚举对应表 game_user_ban.statustinyint
*
* 合法流转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);
}
}

View File

@ -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_typetinyint
*
* 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);
}
}

View File

@ -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 注入时映射R1block 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);
}
}

View File

@ -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新增错误码在此登记
*
* 注意R1publish-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, "分级:无分级记录");
}

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-compliance</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>game-module-compliance-server</artifactId>
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<description>
compliance 模块业务实现controller(app/admin) / service(gate·ban) / dal(DO·Mapper) / convert / 合规原子StyleComplianceAtom·IpComplianceSeam
被 yudao-server 聚合启动(随单体一起加载,无需独立 bootstrap
</description>
<dependencies>
<!-- Spring Cloud 基础环境 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-env</artifactId>
</dependency>
<!-- 本模块对外契约层 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-compliance-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 依赖 project 的 -apiR3 封禁下架联动仅当 game.status==PUBLISHED 时调 project 权威状态机(跨模块只依赖对方 -api禁依赖 -server -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-project-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 业务组件:数据权限 + 多租户DO 继承 TenantBaseDO -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-biz-data-permission</artifactId>
</dependency>
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-biz-tenant</artifactId>
</dependency>
<!-- Web + 安全:@RestController / @PreAuthorize / 当前登录用户 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-security</artifactId>
</dependency>
<!-- DBMyBatis PlusBaseMapperX / LambdaQueryWrapperX / TenantBaseDO -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-mybatis</artifactId>
</dependency>
<!-- 缓存 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-redis</artifactId>
</dependency>
<!-- RPC 远程调用(实现本模块 ComplianceGateApi + 调 project 的 ProjectApi 做下架联动) -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-rpc</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
</build>
</project>

View File

@ -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<GateVerdict> evaluate(ComplianceGateReqDTO reqDTO) {
return success(complianceGateService.evaluate(reqDTO));
}
}

View File

@ -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<Long> 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<Boolean> 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<PageResult<UserBanRespVO>> getBanPage(@Valid BanPageReqVO reqVO) {
PageResult<UserBanDO> pageResult = userBanService.getBanPage(reqVO);
return success(ComplianceConvert.toBanRespPage(pageResult));
}
}

View File

@ -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;
/**
* 解封入参 VOadmin-api对齐 compliance.yaml /admin-api/compliance/ban/cancel
*
* banId 解封status 10合法流转由 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;
}

View File

@ -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;
/**
* 封禁创建入参 VOadmin-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;
}

View File

@ -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 VOadmin对齐 compliance.yaml /admin-api/compliance/ban/page
*
* 分页参数复用 Yudao PageParampageNo/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;
}

View File

@ -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 VOadmin对齐 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;
}

View File

@ -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<ContentRatingRespVO> getRating(@RequestParam("gameId") Long gameId,
@RequestParam(value = "versionId", required = false) Long versionId) {
ContentRatingDO rating = contentRatingService.getRating(gameId, versionId);
return success(ComplianceConvert.toRatingRespVO(rating));
}
}

View File

@ -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 VOapp-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;
}

View File

@ -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<UserBanRespVO> toBanRespPage(PageResult<UserBanDO> page) {
List<UserBanRespVO> 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);
}
}

View File

@ -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 不可重复生效封禁
* 状态机 status1 生效 0 已解封合法流转由 Service 校验DO 层不承载
* R3封禁写台账=主操作恒执行下架联动按 project 状态分支 PUBLISHED 调状态机本波标 TODO
* R5ban_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;
}

View File

@ -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;
/**
* 评估的游戏 IDproject.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;
}

View File

@ -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;
/**
* 游戏 IDproject.game_project.id
*/
private Long gameId;
/**
* 版本 ID无版本时为 0
*/
private Long versionId;
/**
* 内容分级all 全年龄/8+/12+/16+
*/
private String rating;
/**
* 分级来源gate 锁风门自动/admin 人工复核
*/
private String ratedBy;
}

View File

@ -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<ContentRatingDO> {
/**
* 按游戏+版本查内容分级命中 uk_game_versiontenant_id/deleted Yudao 拦截器自动追加
*
* @param gameId 游戏 ID
* @param versionId 版本 ID无版本传 0
* @return 分级 DO不存在返回 null
*/
default ContentRatingDO selectByGameVersion(Long gameId, Long versionId) {
return selectOne(new LambdaQueryWrapperX<ContentRatingDO>()
.eq(ContentRatingDO::getGameId, gameId)
.eq(ContentRatingDO::getVersionId, versionId));
}
}

View File

@ -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<GateResultDO> {
}

View File

@ -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<UserBanDO> {
/**
* 按目标查生效中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<UserBanDO>()
.eq(UserBanDO::getTargetType, targetType)
.eq(UserBanDO::getTargetId, targetId)
.eq(UserBanDO::getStatus, BanStatusEnum.ACTIVE.getStatus()));
}
/**
* 封禁台账分页admin按目标类型/状态筛选命中 idx_type_status
*
* @param reqVO 分页 + 可选筛选
* @return 分页结果
*/
default PageResult<UserBanDO> selectPage(BanPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<UserBanDO>()
.eqIfPresent(UserBanDO::getTargetType, reqVO.getTargetType())
.eqIfPresent(UserBanDO::getStatus, reqVO.getStatus())
.orderByDesc(UserBanDO::getId));
}
}

View File

@ -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);
}
}

View File

@ -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 合规扩展点D5ip 不独立建模块仅留 seam桩实现默认 pass
*
* D5 决策IP 侵权检测本波不独立建模块仅在 compliance 内留扩展点MVP 桩恒返回 pass
* TODOWave4 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) {
// TODOWave4 ip 模块做真实 IP 侵权比对D5 仅留 seam届时只换本实现
return ComplianceAtomResult.pass(ATOM);
}
}

View File

@ -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
* TODOP1 aigc-api 风格内容合规 seamaigc 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) {
// TODOP1 aigc 风格内容合规 seamT-AGC-19 reqDTO.title/summary/promptHash 透传做真实扫描
return ComplianceAtomResult.pass(ATOM);
}
}

View File

@ -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 10记录不存在或已解封报错
*
* @param banId 封禁台账 ID
*/
void cancelBan(Long banId);
/**
* 封禁台账分页admin RBAC
*
* @param reqVO 分页 + 筛选
* @return 分页结果
*/
PageResult<UserBanDO> getBanPage(BanPageReqVO reqVO);
}

View File

@ -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 10
*
* R3 下架联动 / 本波边界 = openIssue
* 设计目标封禁游戏且 game.status==PUBLISHED 时才调 project 权威状态机下架reviewProject decision=3 PUBLISHED 只记台账不调状态机不抛异常
* 现状ProjectApiPhase 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 -apiR3 下架联动收口点预留本波仅持有引用不调用未就绪 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<UserBanDO> 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 状态查询 seamPhase 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) {
// TODOPhase C agent 收口 project reviewProject 状态机下架 PUBLISHED PUBLISHED 只记台账不抛异常
// 本波因 ProjectApi reviewProject/状态查询 seam下架联动不生效openIssueban 台账已写入
log.warn("[maybeUnlistPublishedGame][gameId={} 封禁台账已写入;下架联动 TODO 留 Phase C 收口project-api 暂无 reviewProject seam]", gameId);
}
}

View File

@ -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 verdictmax(各原子)block>review>pass+ 落锁风门台账 + 落内容分级
* 仅做 GateD2绝不重写 project 审核状态机 {@code ComplianceGateApiImpl} 委托project.publish 注入用
*
* @author 造梦AI
*/
public interface ComplianceGateService {
/**
* 锁风门评估聚合合规原子产出裁决并落锁风门台账 + 内容分级
*
* 聚合语义verdict = max(各原子)block>review>passblock 不抛异常正常返回 verdict project 侧据此置 admitted=false
* 写副作用 game_compliance_gate_result每次一条审计+ upsert game_content_rating分级落定
*
* @param reqDTO 评估入参gameId/versionId 必填
* @return 聚合裁决 verdict/rating/detail
*/
GateVerdict evaluate(ComplianceGateReqDTO reqDTO);
}

View File

@ -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
* 仅做 GateD2不碰 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) 逐个执行合规原子风格内容 + IPMVP 均为桩默认 pass
List<ComplianceAtomResult> atomResults = new ArrayList<>();
atomResults.add(styleComplianceAtom.evaluate(reqDTO));
atomResults.add(ipComplianceSeam.evaluate(reqDTO));
// 2) 聚合裁决verdict = max(各原子)block > review > pass
ComplianceVerdictEnum aggregated = ComplianceVerdictEnum.PASS;
List<ComplianceGateItemDTO> 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);
}
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -0,0 +1,84 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块compliancegame-module-complianceWave3 合规域「锁风门 Gate」| ownerWave3 compliance 子 agent
-- 文件V9.0.0__create_game_compliance.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移 V9.0.1 DROP TABLE
-- 内容compliance 模块三张表 —— 锁风门评估台账 game_compliance_gate_result + 内容分级 game_content_rating + 封禁台账 game_user_ban
-- 职责(架构 Doc B + D2 范围铁律):只做 Gate聚合风格/IP 原子→verdict + 内容分级 + 封禁台账);
-- 【明确不建】审核状态机表 / audit_record —— 审核状态机权威归 projectgame_review_recordcompliance 绝不重写。
-- 锁风门 seamR1project.submitPublish 同进程调 ComplianceGateApi.evaluate → 聚合裁决落 game_compliance_gate_result + 分级落 game_content_rating
-- → 返回 verdict 供 project 据此置 admittedblock→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 时落聚合裁决 + 各原子明细 JSONadmin 复核与合规溯源据此查
-- 写口径非幂等每次评估各落一条审计记录不去重verdict 为聚合结果 max(各原子)
-- -----------------------------------------------------------------------------
CREATE TABLE `game_compliance_gate_result` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '锁风门评估记录 ID',
`game_id` BIGINT NOT NULL COMMENT '评估的游戏 IDproject.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 '租户 IDYudao 多租户兼容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 '游戏 IDproject.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 '租户 IDYudao 多租户兼容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)可再次封禁
-- 状态机 status1 生效 → 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 '租户 IDYudao 多租户兼容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 状态分支)';

View File

@ -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-apiseam 未就绪
* - 封禁用户写台账用户目标无下架联动
* - 查重同目标已有生效封禁 COMPLIANCE_BAN_ALREADY_EXISTS不再插入
* - 类型非法 COMPLIANCE_BAN_TARGET_INVALID
*
* 说明 R3/openIssueProjectApi 本波无 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<UserBanDO> 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-apireviewProject 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<UserBanDO> 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;
}
}

View File

@ -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=blockblock>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<GateResultDO> 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() {
// 准备风格原子 blockIP 原子 pass 聚合应为 blockblock>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<GateResultDO> 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;
}
}

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<!-- 父:沿用 yudao 聚合父 POMgroupId 复用 cn.iocoder.cloud 仅为 Maven 坐标;业务代码包名为 cn.wanxiang.game拆仓时再改坐标 -->
<parent>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<!-- compliance 模块Wave3 合规域「锁风门 Gate」-api对外契约层/ -server业务实现层克隆 ad 黄金模板范式 -->
<modules>
<module>game-module-compliance-api</module>
<module>game-module-compliance-server</module>
</modules>
<artifactId>game-module-compliance</artifactId>
<packaging>pom</packaging>
<name>${project.artifactId}</name>
<description>
造梦AI - compliance 模块(合规锁风门 Gate发布前合规扫描聚合风格/IP 原子→verdict+ 内容分级 + 封禁台账。
D2 范围铁律:只做 Gate绝不重写 project 审核状态机(审核状态机权威归 project只交付 ComplianceGateApi.evaluate seam 供 project.publish 注入。
错误码段 = 1-109-***-***。Flyway = V9。
</description>
</project>

View File

@ -31,6 +31,13 @@
<version>${revision}</version>
</dependency>
<!-- 跨模块契约project -apiGAP-3 分享回填 currentVersionId仅依赖 -api 不依赖 -server -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-project-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 业务组件:数据权限 + 多租户DO 继承 TenantBaseDO -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>

View File

@ -33,4 +33,7 @@ public class ShareMetaRespVO {
@Schema(description = "回显的渠道标识(渠道归因 T-FED-10", example = "wechat")
private String channel;
@Schema(description = "当前生效版本 IDGAP-3与 FeedItem.versionId 同源 project.currentVersionId仅已发布游戏回填否则为空", example = "2048")
private Long versionId;
}

View File

@ -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分享元数据回填当前生效版本 IDgetCurrentVersionId供分享落地页直达即玩加载
* 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 不存在/无生效版本返回 nullcode=0 + data=null
// getCheckedData() 不抛异常此处随之留空不阻断分享
meta.setVersionId(resolveCurrentVersionId(gameId, published));
return meta;
}
/**
* 解析当前生效版本 IDGAP-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可能为 nullproject 侧约定无生效版本即 null 0 抛异常
return projectApi.getCurrentVersionId(gameId).getCheckedData();
}
// ============================== 精选池运营 ==============================
@Override

View File

@ -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 ==============================

View File

@ -26,6 +26,10 @@ public class RuntimePackageRespVO {
@Schema(description = "GamePackagemanifestOSS/CDN URL按 /games/{gameId}/versions/{versionId}/ 版本化)")
private String packageUrl;
@Schema(description = "manifest.json 取包清单 URLGAP-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;

View File

@ -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纯派生 DDLmanifestUrl 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 取包清单 URLGAP-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 清单 URLpackageUrl 空时返回 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";
}
// ============================== 编译任务 ==============================
/**

View File

@ -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 分支AmanifestUrl 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));
}
}

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-studio</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>game-module-studio-api</artifactId>
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<description>
studio 模块 API错误码常量、创作会话状态机枚举。
MVP 创作主链路 studio 为 /app-api 编排入口(不对外暴露跨模块 Feign 契约),本层仅定枚举与错误码段。
</description>
<dependencies>
<!-- 通用基础ErrorCode / CommonResult / PageResult 等 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-common</artifactId>
</dependency>
<!-- 接口文档Swagger 模型注解(仅编译期需要) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<scope>provided</scope>
</dependency>
<!-- 参数校验注解 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<optional>true</optional>
</dependency>
<!-- RPC 远程调用(跨模块 Feign按需启用 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@ -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本类保留以备拆微服务与统一前缀
* - NAMEFeign 目标服务名MVP 为单体spring.application.name=yudao-server所有模块编入一个 JAR
* - PREFIXRPC 接口前缀 /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";
}

View File

@ -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 调用异常)");
}

View File

@ -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 最小版草稿 生成中 完成/失败线性单步任务链 DRAFTGENERATINGDONE/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);
}
}

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-studio</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>game-module-studio-server</artifactId>
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<description>
studio 模块业务实现controller(app) / service / dal(DO·Mapper) / convert。
编排调 project-api(createProject 建草稿) + aigc-api(submitGenerate/getTask/retryTask)。
被 yudao-server 聚合启动(随单体一起加载,无需独立 bootstrap
</description>
<dependencies>
<!-- Spring Cloud 基础环境 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-env</artifactId>
</dependency>
<!-- 本模块对外契约层(错误码/枚举) -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-studio-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 编排依赖 project 的 -apicreateDraft 调 createProject 建游戏拿 gameId跨模块只依赖对方 -api禁依赖 -server -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-project-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 编排依赖 aigc 的 -apigenerate/getTask/regenerate 委托 AigcApi跨模块只依赖对方 -api禁依赖 -server -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-aigc-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 业务组件:数据权限 + 多租户DO 继承 TenantBaseDO -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-biz-data-permission</artifactId>
</dependency>
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-biz-tenant</artifactId>
</dependency>
<!-- Web + 安全:@RestController / 当前登录用户 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-security</artifactId>
</dependency>
<!-- DBMyBatis PlusBaseMapperX / LambdaQueryWrapperX / TenantBaseDO -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-mybatis</artifactId>
</dependency>
<!-- 缓存 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-redis</artifactId>
</dependency>
<!-- RPC 远程调用(调 project 的 ProjectApi + aigc 的 AigcApi 做编排) -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-rpc</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
</build>
</project>

View File

@ -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 轮询非 MQD4模板浏览 建草稿(委托 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<List<AigcTemplateDTO>> getTemplateList() {
return success(studioService.getTemplateList());
}
@PostMapping("/draft")
@Operation(summary = "建草稿", description = "进工作坊P-CRT-10+ 草稿移交 projectP-TPL-03调 createProject 拿 gameId 回写会话")
public CommonResult<StudioSessionRespVO> 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<StudioTaskChainRespVO> 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 = "创作任务链 IDtaskChainId", required = true, example = "1024")
public CommonResult<StudioTaskChainRespVO> 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<StudioTaskChainRespVO> regenerate(@PathVariable("id") Long id) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
StudioTaskChainDO chain = studioService.regenerate(id, userId);
return success(StudioConvert.toTaskChainRespVO(chain));
}
}

View File

@ -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 StudioDraftCreateReqVOP-CRT-10 进工作坊 + P-TPL-03 草稿移交 project
*
* 编排服务端调 ProjectApi.createProject(creatorUserId, title, templateId) 建游戏拿 gameId 回写会话 upsert 会话(status=0)
* attachmentsJSON 字符串持久化P-CRT-04本波仅持久化不透传 aigcR7
*
* @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 = "附件上下文 JSONP-CRT-04本波仅持久化P1 透传 aigc")
private String attachments;
}

View File

@ -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 StudioGenerateReqVOP-CRT-01 一句话生成
*
* 编排校验 session 归属 落任务链(status=1) 委托 AigcApi.submitGenerate(prompt/templateId/gameId/userId) 回填 aigc_task_id
* 同步直写无 MQD4attachments 仅持久化R7不入 aigc reqDTOaigc 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 = "附件上下文 JSONP-CRT-04【本波仅持久化P1 透传 aigc】——studio 侧落库持久化,本波不透传 aigc")
private String attachments;
}

View File

@ -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 = "创作会话 IDsessionId", example = "1024")
private Long id;
@Schema(description = "移交后的游戏 IDproject.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;
}

View File

@ -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 StudioTaskChainRespVOgenerate/轮询/重生成返回
*
* 进度轮询P-CRT-12前端据本 VO status/progress 轮询直至终态status=2 完成后 versionId 非空
*
* @author 造梦AI
*/
@Schema(description = "产品端 - 创作任务链 Response VO")
@Data
public class StudioTaskChainRespVO {
@Schema(description = "创作任务链 IDtaskChainId轮询用", example = "1024")
private Long id;
@Schema(description = "所属创作会话 ID", example = "512")
private Long sessionId;
@Schema(description = "委托的 aigc 生成任务 ID", example = "8888")
private Long aigcTaskId;
@Schema(description = "关联游戏 IDproject.game_project.id", example = "2048")
private Long gameId;
@Schema(description = "产物版本 IDproject.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 = "若为重生成,指向原任务链 IDP-CRT-09 血缘)", example = "1000")
private Long retryOf;
}

View File

@ -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;
}
}

View File

@ -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/草稿 scopeMVP 仅落槽位占位 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/iconMVP 槽位占位
*/
private String assetType;
/**
* 资产 URLOSS/MinIO单步整包产出后登记
*/
private String assetUrl;
/**
* 槽位状态0未生成 1已生成Service 校验MVP 单步直填
*/
private Integer status;
}

View File

@ -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;
/**
* 移交后的游戏 IDproject.game_project.idcreateDraft 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-04JSON 字符串本波仅持久化P1 透传 aigc
*/
private String attachments;
/**
* 会话状态机枚举 {@link StudioSessionStatusEnum}0草稿 1生成中 2完成 3失败
*/
private Integer status;
}

View File

@ -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生成权威归 aigcversion_id 引用 project.game_version.id
* 版本权威归 projectaigc 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;
/**
* 创作者用户 IDDataPermission 边界归属校验用
*/
private Long creatorUserId;
/**
* 委托的 aigc 生成任务 ID= aigc.game_aigc_task.idgenerate 后回填轮询据此查 aigc
*/
private Long aigcTaskId;
/**
* 关联游戏 IDproject.game_project.id透传 aigc
*/
private Long gameId;
/**
* 产物版本 IDproject.game_version.idaigc succeeded 后由 getTask 回填
*/
private Long versionId;
/**
* 任务链状态机0初始 1生成中 2完成 3失败透传 aigc 终态映射Service 校验流转
*/
private Integer status;
/**
* 进度百分比 0-100透传 aigc progress供前端进度条 P-CRT-12
*/
private Integer progress;
/**
* 若为重生成指向原任务链 IDP-CRT-09 重生成血缘
*/
private Long retryOf;
}

View File

@ -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<StudioAssetDO> {
/**
* 按会话查资产槽位列表命中 idx_session_type(session_id, asset_type)
*
* @param sessionId 会话 ID
* @return 资产槽位列表
*/
default List<StudioAssetDO> selectListBySessionId(Long sessionId) {
return selectList(new LambdaQueryWrapperX<StudioAssetDO>()
.eq(StudioAssetDO::getSessionId, sessionId)
.orderByAsc(StudioAssetDO::getId));
}
}

View File

@ -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<StudioSessionDO> {
/**
* 我的创作会话分页app 创作者只见自己数据命中 idx_creator(creator_user_id, id)
*
* @param pageParam 分页参数
* @param creatorUserId 当前登录创作者 IDDataPermission 边界
* @return 分页结果
*/
default PageResult<StudioSessionDO> selectMyPage(PageParam pageParam, Long creatorUserId) {
return selectPage(pageParam, new LambdaQueryWrapperX<StudioSessionDO>()
.eq(StudioSessionDO::getCreatorUserId, creatorUserId) // 归属边界只查自己
.orderByDesc(StudioSessionDO::getId));
}
}

View File

@ -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<StudioTaskChainDO> {
/**
* 按会话查任务链列表含重生成历史 id 倒序命中 idx_session(session_id, id)
*
* @param sessionId 会话 ID
* @return 任务链列表
*/
default List<StudioTaskChainDO> selectListBySessionId(Long sessionId) {
return selectList(new LambdaQueryWrapperX<StudioTaskChainDO>()
.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);
}
}

View File

@ -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 轮询非 MQD4模板浏览 建草稿(委托 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<AigcTemplateDTO> 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 reqDTOR7同步直写无 MQD4
*
* @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);
}

View File

@ -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生成权威归 aigcstudio 只持引用 + 链态
* - 进度轮询非 MQD4同步透传 aigc getTask
* - attachments 仅持久化R7不入 aigc reqDTO
* 跨模块只依赖对方 -apiProjectApi / AigcApi FeignMVP 单体由对方 @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 -apiWave3 新增 seamMVP 单体 aigc AigcApiImpl(@RestController @Primary) 就地解析
*/
@Resource
private AigcApi aigcApi;
@Override
public List<AigcTemplateDTO> 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 草稿回写移交后的 gameIdattachments 仅持久化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/userIdattachments 不透传R7同步直写无 MQD4
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) 未委托 aigcaigc_task_id 为空理论不可达 直接返回当前态
if (chain.getAigcTaskId() == null) {
return chain;
}
// 3) 委托 aigc getTask 取进度/状态透传 MQD4
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);
}
}

View File

@ -0,0 +1,92 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块studiogame-module-studioWave3 创作主链路最小版)| ownerstudio 路
-- 文件V8.0.0__create_game_studio.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移 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
-- 边界纪律:
-- - 项目/版本权威归 projectgame_project / game_versionstudio 仅持 game_id/version_id 引用,不在本模块建版本表。
-- - 生成任务权威归 aigcgame_aigc_taskstudio 任务链仅持 aigc_task_id 引用 + 自身链态,不在本模块重建生成状态机。
-- - attachments 本波仅持久化R7不透传 aigcaigc 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草稿移交 projectP-TPL-03
-- attachmentsJSON 持久化(本波仅持久化不透传 aigcR7
-- 状态机 status0 草稿 → 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 '移交后的游戏 IDproject.game_project.idcreateDraft 调 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-04JSON 数组本波仅持久化P1 透传 aigc',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '会话状态机0草稿 1生成中 2完成 3失败StudioSessionStatusEnumService 校验流转)',
`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 '租户 IDYudao 多租户兼容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_idgetTask 轮询透传 aigc 进度/状态映射链态。
-- 引用边界aigc_task_id 引用 aigc.game_aigc_task.id生成权威归 aigcversion_id 引用 project.game_version.id
-- aigc succeeded 后回填,版本权威归 project本表只持引用 + 自身链态,不重建生成状态机。
-- 重生成血缘retry_of 指向原任务链 idP-CRT-09regenerate 新建一条 retry_of=原 id 的链。
-- 链态 status0 初始 → 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 '创作者用户 IDDataPermission 边界,归属校验用)',
`aigc_task_id` BIGINT NULL DEFAULT NULL COMMENT '委托的 aigc 生成任务 ID= aigc.game_aigc_task.idgenerate 后回填,轮询据此查 aigc',
`game_id` BIGINT NULL DEFAULT NULL COMMENT '关联游戏 IDproject.game_project.id透传 aigc 用)',
`version_id` BIGINT NULL DEFAULT NULL COMMENT '产物版本 IDproject.game_version.idaigc 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 '若为重生成,指向原任务链 IDP-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 '租户 IDYudao 多租户兼容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-02MVP 仅落槽位 seam不六路并发
-- 用途单步生成产物的资产槽位记录asset_type 槽位 + asset_url + 槽位状态)。
-- 狠切R7/草稿 scopeMVP 只建槽位表占位 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/iconMVP 槽位占位)',
`asset_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '资产 URLOSS/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 '租户 IDYudao 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
KEY `idx_session_type` (`session_id`, `asset_type`) COMMENT '按会话+槽位类型查资产'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作资产槽位表(单步出整包槽位 seamMVP 不六路并发)';

View File

@ -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-apicreateProject gameId 回写会话调用异常/ gameId 抛错
* - generate 委托 aigc落任务链 + submitGenerate 回填 aigc_task_idattachments 不透传 aigcR7
* - getTaskChain 状态流转aigc succeeded链完成回填 version_idfailed链失败
* - 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<StudioSessionDO> 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/userIdattachments 不在 reqDTOR7
ArgumentCaptor<AigcGenerateReqDTO> 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回写会话 attachmentsAigcGenerateReqDTO 无此字段编译期即保证不透传
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<StudioSessionDO> 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;
}
}

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<!-- 父:沿用 yudao 聚合父 POMgroupId 复用 cn.iocoder.cloud 仅为 Maven 坐标;业务代码包名为 cn.wanxiang.game拆仓时再改坐标 -->
<parent>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<!-- studio 模块Wave3 创作主链路最小版):-api对外契约层/ -server业务实现层克隆 ad 黄金模板范式 -->
<modules>
<module>game-module-studio-api</module>
<module>game-module-studio-server</module>
</modules>
<artifactId>game-module-studio</artifactId>
<packaging>pom</packaging>
<name>${project.artifactId}</name>
<description>
造梦AI - studio 模块(创作主链路最小版,与 game-studio 产品端前端仓区分):有状态创作工作台后端,
编排「建草稿(委托 project createProject)→一句话生成(委托 aigc)→进度轮询→重生成→模板应用」。
不跑 LLM委托 aigc、不持久化项目版本交 project、不做锁风裁决调 compliance
MVP 轮询非 MQattachments 本波仅持久化不透传 aigc。错误码段 = 1-112-***-***Flyway = V8。
</description>
</project>

View File

@ -29,6 +29,9 @@
<module>game-module-ad</module>
<!-- Wave2 变现域trade分账/结算/提现,钱财闭环下游,消费 ad 收入做 T+1 分账) -->
<module>game-module-trade</module>
<!-- Wave3compliance锁风门 Gate/分级/封禁发布前合规、studio创作主链路编排调 project/aigc -->
<module>game-module-compliance</module>
<module>game-module-studio</module>
</modules>
<name>${project.artifactId}</name>

View File

@ -69,6 +69,17 @@
<artifactId>game-module-trade-server</artifactId>
<version>${revision}</version>
</dependency>
<!-- Wave3 模块compliance 锁风门 Gate / studio 创作主链路编排;聚合进单体,全局扫描已覆盖 cn.wanxiang.game.module -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-compliance-server</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-studio-server</artifactId>
<version>${revision}</version>
</dependency>
<!-- Flyway 数据库迁移(版本由 spring-boot-dependencies 管理 = 11.xMySQL 需 flyway-mysql -->
<dependency>
<groupId>org.flywaydb</groupId>

View File

@ -8,6 +8,7 @@ import type {
ProjectCreateReq,
ProjectDraftReq,
ProjectPublishReq,
ProjectPublishResp,
} from './types'
/**
@ -67,13 +68,20 @@ export function saveDraft(id: number, dto: ProjectDraftReq): Promise<boolean> {
}
/**
* 7 BPM
*
* POST /app-api/project/{id}/publish
*
* GAP-1in-place boolean
* {@link ProjectPublishResp}admitted + gates[]/稿
* code0 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<boolean> {
return request<boolean>({
export function publish(id: number, dto: ProjectPublishReq): Promise<ProjectPublishResp> {
return request<ProjectPublishResp>({
url: `/app-api/project/${id}/publish`,
method: 'post',
data: dto,

View File

@ -88,6 +88,11 @@ export interface InteractResult {
/** 分享落地页 OG 元数据feed.yaml ShareMetaRespVO */
export interface ShareMeta {
gameId: number
/**
* IDGAP-3additive 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 URLGAP-2additive宿 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 ============================== */
/**

View File

@ -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. bridgepostMessage 收发 + 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';
// propsspec §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<GamePackage> {
// 1) manifest
if (props.manifest) return props.manifest;
// 2) versionId runtime.getPackage #1 RuntimePackageRespVO
// manifest URL OSS
// demo Runtimeassets 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

View File

@ -198,6 +198,12 @@ export interface RuntimePackageRespVO {
versionId: number;
templateId: string;
packageUrl: string;
/**
* manifest.json URLGAP-2additive runtime.yaml RuntimePackageRespVO.manifestUrl
* 宿 fetch GamePackage#4 checksum sha256 iframeT-RT-15
* packageUrl 宿退 demo GamePlayer.resolvePackage
*/
manifestUrl?: string;
entry: string;
runtimeVersion: string;
preloadPolicy: 'eager' | 'lazy';

View File

@ -132,6 +132,72 @@ function safeJson(obj: unknown): string {
return JSON.stringify(obj).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
}
/**
* sha256hex Web Crypto SubtleCrypto/
* @param text manifest.json
* @returns 64 sha256 crypto.subtle
*/
async function sha256Hex(text: string): Promise<string> {
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 iframeinject JSON.stringify
* manifestUrl /CORS runtime sandbox.allowOrigins + OSS CORS
*
* @param manifestUrl manifest.json URLruntime
* @param expectedChecksum sha256hex checksum
* @returns GamePackage
* @throws / JSON / / checksum / crypto.subtle
*/
export async function fetchAndVerifyManifest(
manifestUrl: string,
expectedChecksum?: string,
): Promise<GamePackage> {
// 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

View File

@ -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),

View File

@ -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-1publish boolean ProjectPublishRespVO{admitted, gates[]}
* project.yaml mock {admitted, gates}
* - /稿/ = gates
* - (age) = admitted=false gate.pass=false
* gate.key status/title/cover/version/age/zone/compliancecompliance 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/publishGAP-1 ProjectPublishResp
*
* gates
* - / 稿 / versionId+launchZoneId
* gates admitted /
* - age ageRating pass=falseadmitted=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<ProjectPublishReq>
// 契约 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 之前) */

View File

@ -36,6 +36,13 @@ function packageHandler(req: MockRequest): RuntimePackage {
// 模板clicker与 demo 点击计数玩法一致)
templateId: 'clicker',
packageUrl: `/games/${gameId}/versions/${versionId}/manifest.json`,
// GAP-2mock 刻意「不」回填 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',

View File

@ -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<boolean> {
async function publish(id: number, dto: ProjectPublishReq): Promise<ProjectPublishResp> {
return projectApi.publish(id, dto)
}

View File

@ -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.publishPOST /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<number | undefined>(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<GateResult[]>([])
/** 服务端门禁中的失败项(高亮展示用) */
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 trueok=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(() => {
</div>
</main>
<!-- 失败态列出失败原因 + 返回修改 -->
<!-- 失败态服务端逐项门禁权威高亮失败项 + 返回修改 -->
<main v-else-if="phase === 'failed'" class="publish__result">
<div class="publish__result-icon publish__result-icon--fail">!</div>
<h2 class="publish__result-title">发布前检查未通过</h2>
<!-- 服务端返回的权威失败原因 -->
<h2 class="publish__result-title">发布前门禁未通过</h2>
<!-- 失败原因门禁拦截=首条失败门禁 message安全前置=服务端 msg -->
<p class="publish__result-desc publish__result-desc--fail">{{ failReason }}</p>
<!-- 客户端预检失败项辅助创作者定位需补充的资料 -->
<ul v-if="failedGates.length" class="publish__fail-list">
<!-- GAP-1服务端门禁逐项结果权威仅在 admitted=false 拿到 gates 时展示高亮失败项 -->
<ul v-if="serverFailedGates.length" class="publish__fail-list">
<li v-for="g in serverFailedGates" :key="g.key" class="publish__fail-item">
<span class="publish__fail-dot"></span>
<span>{{ g.label || g.key }}{{ g.message || '未通过' }}</span>
</li>
</ul>
<!-- 兜底走安全前置抛错路径serverGates 为空附客户端预检失败项辅助定位 -->
<ul v-else-if="failedGates.length" class="publish__fail-list">
<li v-for="g in failedGates" :key="g.key" class="publish__fail-item">
<span class="publish__fail-dot"></span>
<span>{{ g.label }}{{ g.hint }}</span>
</li>
</ul>
<div class="publish__result-actions">
<AppButton type="primary" block @click="goEdit">返回修改</AppButton>
<AppButton type="ghost" block @click="retry">重新检查</AppButton>
@ -226,14 +250,14 @@ onMounted(() => {
<!-- idle 发布资料预检清单 + 专区选择 + 提交 -->
<main v-else class="publish__body">
<!-- 契约边界提示让创作者知道最终以审核为准 -->
<!-- 契约边界提示客户端预检仅辅助权威以服务端门禁为准 -->
<p class="publish__tip">
以下为发布前自检清单最终是否通过以平台审核为准
以下为发布前自检清单辅助提示最终准入以提交后服务端门禁结果为准
</p>
<!-- 7 门禁预检清单 -->
<!-- 客户端预检清单辅助引导非权威 -->
<section class="publish__gates">
<h3 class="publish__section-title">发布前检查7 </h3>
<h3 class="publish__section-title">发布前自检辅助</h3>
<ul class="gate-list">
<li v-for="g in gates" :key="g.key" class="gate-item" :class="{ 'is-fail': !g.pass }">
<span class="gate-item__icon" :class="g.pass ? 'is-pass' : 'is-fail'">

View File

@ -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'

View File

@ -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仅带 gameIdmock 阶段宿主 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}`)
}
/** 返回游戏流(落地页二级入口) */