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:
parent
096020fa0a
commit
b70e90b775
218
contracts/api-schemas/compliance.yaml
Normal file
218
contracts/api-schemas/compliance.yaml
Normal file
@ -0,0 +1,218 @@
|
||||
openapi: 3.0.3
|
||||
# 契约 #1 API | 模块:compliance(game-module-compliance,Wave3 合规域「锁风门 Gate」)| owner:Wave3 compliance 子 agent 主笔,全员 review
|
||||
# 职责(架构 Doc B + D2 范围铁律):只做 Gate——发布前合规扫描(聚合风格/IP 原子→verdict)+ 内容分级 + 封禁台账。
|
||||
# 【明确不做】审核状态机:审核状态机权威归 project(game_review_record),compliance 绝不重写(D2)。
|
||||
# seam(R1):compliance 只交付 ComplianceGateApi.evaluate 供 project.submitPublish 同进程注入(block→admitted=false 不抛异常);
|
||||
# 不自改 submitPublish、不自登记 publish-blocked 错误码(那条文案由 project 侧 GateResultVO.code 携带)。
|
||||
# 封禁(R3 状态分支):写 game_user_ban 台账=主操作恒执行;下架联动仅当游戏==PUBLISHED 时才调 project 状态机;
|
||||
# 本波 ProjectApi 暂无 reviewProject seam,下架联动标 TODO 留 Phase C 主 agent 收口(只写台账、不裸调不存在的方法)。
|
||||
# 降权(R5):降权出流屏蔽 P1 本波不生效(不建 FeedDownweightApi 孤儿 seam、不调 feed)。
|
||||
# IP(D5):ip 不独立建模块,仅在 compliance 内留 IpComplianceSeam 扩展点(默认 pass 桩),Wave4 接 ip 只换实现。
|
||||
# 端:/rpc-api(project.publish 同进程 Feign 注入)/ /admin-api(管理端 game-admin,RBAC 封禁)/ /app-api(产品端只读分级);前缀由 yudao 框架按 controller 包名/RpcConstants 自动添加
|
||||
# 错误码段:compliance = 1-109-***-***(本模块独占,禁止与他模块重叠:100 project/101 aigc/102 runtime/103 feed/104 telemetry/106 trade/111 ad/112 studio)
|
||||
# publish-blocked 不在本模块登记(R1:block→admitted=false 不抛异常)。
|
||||
# 响应统一 Yudao CommonResult 信封:{ code, data, msg };code=0 成功
|
||||
info:
|
||||
title: 造梦AI compliance 模块 API
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
合规锁风门 Gate("做得出"安全闸门)。project.publish 发布前同步调 evaluate → 聚合风格/IP 合规原子产出 verdict
|
||||
(pass/review/block)+ 落锁风门台账 + 落内容分级;block 则 project 置 admitted=false(不抛异常)。
|
||||
封禁台账供 admin 处置游戏/用户;下架联动按 project 状态分支(本波 TODO 留 Phase C)。MVP 合规原子全 mock 桩(默认 pass)。
|
||||
|
||||
servers:
|
||||
- url: http://localhost:48080
|
||||
description: 本地(Swagger/Knife4j http://localhost:48080/doc.html)
|
||||
|
||||
paths:
|
||||
# ===========================================================================
|
||||
# RPC 端 /rpc-api(project.publish 同进程注入,唯一 seam,R1)
|
||||
# ===========================================================================
|
||||
/rpc-api/compliance/evaluate:
|
||||
post:
|
||||
tags: [rpc-compliance]
|
||||
summary: 锁风门评估(project.publish 注入用,R1 唯一形态)
|
||||
description: >-
|
||||
project.submitPublish 发布前同步调;聚合风格/IP 合规原子产出 verdict = max(各原子)(block>review>pass),
|
||||
落 game_compliance_gate_result 审计台账 + upsert game_content_rating 分级。
|
||||
block 仍正常返回 verdict(不抛异常),由 project 侧据此置 admitted=false。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ComplianceGateReqDTO' }
|
||||
responses:
|
||||
'200':
|
||||
description: 聚合裁决
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultGateVerdict' }
|
||||
|
||||
# ===========================================================================
|
||||
# 管理端 /admin-api(封禁台账,RBAC,R3 状态分支)
|
||||
# ===========================================================================
|
||||
/admin-api/compliance/ban/create:
|
||||
post:
|
||||
tags: [admin-compliance]
|
||||
summary: 创建封禁(写台账=主操作恒执行;游戏下架联动按 project 状态分支)
|
||||
description: >-
|
||||
落 game_user_ban(主操作恒执行,同目标生效中封禁查重 uk_target_active)。
|
||||
R3 下架联动:targetType=游戏 且 game.status==PUBLISHED 时才调 project reviewProject(decision=3);
|
||||
非 PUBLISHED 只记封禁、不调状态机、不抛异常。【本波】ProjectApi 暂无 reviewProject seam,下架联动 TODO 留 Phase C。
|
||||
R5 降权出流屏蔽 P1 本波不生效(不调 feed)。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/BanCreateReqVO' }
|
||||
responses:
|
||||
'200':
|
||||
description: 新建封禁台账 ID
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultLong' }
|
||||
/admin-api/compliance/ban/cancel:
|
||||
post:
|
||||
tags: [admin-compliance]
|
||||
summary: 解封(status 1→0)
|
||||
description: 按 banId 解封;记录不存在或已解封报 COMPLIANCE_BAN_NOT_EXISTS。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/BanCancelReqVO' }
|
||||
responses:
|
||||
'200':
|
||||
description: 是否成功
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultBoolean' }
|
||||
/admin-api/compliance/ban/page:
|
||||
get:
|
||||
tags: [admin-compliance]
|
||||
summary: 封禁台账分页(admin RBAC)
|
||||
parameters:
|
||||
- { name: pageNo, in: query, required: true, schema: { type: integer, default: 1 } }
|
||||
- { name: pageSize, in: query, required: true, schema: { type: integer, default: 10 } }
|
||||
- { name: targetType, in: query, required: false, schema: { type: integer }, description: '目标类型过滤:1 游戏/2 用户' }
|
||||
- { name: status, in: query, required: false, schema: { type: integer }, description: '状态过滤:1 生效/0 已解封' }
|
||||
responses:
|
||||
'200':
|
||||
description: 分页结果
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultPageUserBan' }
|
||||
|
||||
# ===========================================================================
|
||||
# 产品端 /app-api(内容分级只读,用户/匿名 Token)
|
||||
# ===========================================================================
|
||||
/app-api/compliance/rating/get:
|
||||
get:
|
||||
tags: [app-compliance]
|
||||
summary: 取内容分级(只读)
|
||||
description: 按游戏+版本读分级(all/8+/12+/16+);无记录报 COMPLIANCE_RATING_NOT_FOUND。分级写入仅由 Gate evaluate + admin 复核产生。
|
||||
parameters:
|
||||
- { name: gameId, in: query, required: true, schema: { type: integer, format: int64 } }
|
||||
- { name: versionId, in: query, required: false, schema: { type: integer, format: int64 }, description: '无版本传 0' }
|
||||
responses:
|
||||
'200':
|
||||
description: 内容分级
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultContentRating' }
|
||||
|
||||
components:
|
||||
schemas:
|
||||
# ===== 锁风门 Gate(RPC seam,R1 唯一形态)=====
|
||||
ComplianceGateReqDTO:
|
||||
type: object
|
||||
required: [gameId, versionId]
|
||||
properties:
|
||||
gameId: { type: integer, format: int64, description: '游戏项目 ID(锁风门定位锚点)' }
|
||||
versionId: { type: integer, format: int64, description: '待发布版本 ID' }
|
||||
title: { type: string, description: '标题(风格原子参考,可空)' }
|
||||
summary: { type: string, description: '摘要(风格原子参考,可空)' }
|
||||
ageRating: { type: string, description: '适龄分级建议:all/8+/12+/16+' }
|
||||
promptHash: { type: string, description: '生成 Prompt 摘要(合规溯源,可空)' }
|
||||
GateVerdict: # = 草案 ComplianceGateRespDTO,R1 命名对齐
|
||||
type: object
|
||||
required: [verdict]
|
||||
properties:
|
||||
verdict: { type: string, enum: [pass, review, block], description: '聚合裁决 = max(各原子),block>review>pass' }
|
||||
rating: { type: string, description: '本次评估落定的内容分级' }
|
||||
detail: { type: array, items: { $ref: '#/components/schemas/ComplianceGateItemDTO' } }
|
||||
ComplianceGateItemDTO:
|
||||
type: object
|
||||
properties:
|
||||
atom: { type: string, description: '原子标识:textContent/imageContent/ip-reserved' }
|
||||
verdict: { type: string, enum: [pass, review, block] }
|
||||
reason: { type: string, description: '裁决原因(pass 可空)' }
|
||||
|
||||
# ===== 封禁(admin,R3 状态分支)=====
|
||||
BanCreateReqVO:
|
||||
type: object
|
||||
required: [targetType, targetId]
|
||||
properties:
|
||||
targetType: { type: integer, description: '封禁目标类型:1 游戏 / 2 用户' }
|
||||
targetId: { type: integer, format: int64, description: '封禁目标 ID(游戏=game_id / 用户=user_id)' }
|
||||
banType: { type: integer, description: '处置类型:1 封禁 / 2 降权(P1 本波不生效)' }
|
||||
reason: { type: string, description: '封禁/降权原因' }
|
||||
BanCancelReqVO:
|
||||
type: object
|
||||
required: [banId]
|
||||
properties:
|
||||
banId: { type: integer, format: int64, description: '封禁台账记录 ID' }
|
||||
UserBanRespVO:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
targetType: { type: integer, description: '1 游戏 / 2 用户' }
|
||||
targetId: { type: integer, format: int64 }
|
||||
banType: { type: integer, description: '1 封禁 / 2 降权' }
|
||||
status: { type: integer, description: '1 生效 / 0 已解封' }
|
||||
reason: { type: string }
|
||||
createTime: { type: string, format: date-time }
|
||||
|
||||
# ===== 分级(app 只读)=====
|
||||
ContentRatingRespVO:
|
||||
type: object
|
||||
properties:
|
||||
rating: { type: string, description: 'all/8+/12+/16+' }
|
||||
ratedBy: { type: string, description: 'gate 锁风门自动 / admin 人工复核' }
|
||||
updateTime: { type: string, format: date-time }
|
||||
|
||||
# ===== CommonResult 信封 =====
|
||||
CommonResultGateVerdict:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data: { $ref: '#/components/schemas/GateVerdict' }
|
||||
msg: { type: string }
|
||||
CommonResultContentRating:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data: { $ref: '#/components/schemas/ContentRatingRespVO' }
|
||||
msg: { type: string }
|
||||
CommonResultPageUserBan:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
list: { type: array, items: { $ref: '#/components/schemas/UserBanRespVO' } }
|
||||
total: { type: integer, format: int64 }
|
||||
msg: { type: string }
|
||||
CommonResultLong:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data: { type: integer, format: int64 }
|
||||
msg: { type: string }
|
||||
CommonResultBoolean:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data: { type: boolean }
|
||||
msg: { type: string }
|
||||
@ -261,6 +261,7 @@ components:
|
||||
ogImage: { type: string, description: 'OG 封面图 URL' }
|
||||
ogDescription: { type: string, description: 'OG 描述' }
|
||||
channel: { type: string, description: 回显的渠道标识(渠道归因 T-FED-10) }
|
||||
versionId: { type: integer, format: int64, description: '当前生效版本 ID(GAP-3,与 FeedItem.versionId 同源 project.currentVersionId;仅已发布游戏回填,未发布或无生效版本留空,供分享落地页直达即玩加载)' }
|
||||
|
||||
# ---- 精选池运营 ----
|
||||
FeaturedReqVO:
|
||||
|
||||
@ -210,12 +210,17 @@ components:
|
||||
# ---- 运行包清单(脊柱对外产物:宿主据此加载 + 校验,对齐 #4 manifest 段)----
|
||||
RuntimePackageRespVO:
|
||||
type: object
|
||||
description: 版本运行包清单。字段语义对齐 GamePackage(#4).manifest,宿主据此渲染 iframe 并桥接 SDK(#3)。
|
||||
description: >-
|
||||
版本运行包清单。字段语义对齐 GamePackage(#4).manifest,宿主据此渲染 iframe 并桥接 SDK(#3)。
|
||||
【GAP-2 取包约束】① 宿主先 fetch manifestUrl 取 manifest,按 checksum 做 sha256 完整性校验通过后再注入运行容器(T-RT-15),校验失败拒绝加载并落 error;
|
||||
② OSS/CDN 须放行宿主 origin 的 CORS(AllowedOrigin=宿主 origin、AllowedMethod=GET/HEAD、ExposeHeader=ETag),否则宿主跨域 fetch 被拦;
|
||||
③ scene=preview|play 鉴权与取包门禁须一致:preview 仅版本 owner 可取未发布包、play 仅放行已发布包(与 getPackageManifest 门禁同源,不在前端兜底)。
|
||||
properties:
|
||||
gameId: { type: integer, format: int64, description: 游戏 ID }
|
||||
versionId: { type: integer, format: int64, description: 版本 ID }
|
||||
templateId: { type: string, description: 玩法模板 ID(宿主据此选 Runtime 容器) }
|
||||
packageUrl: { type: string, description: 'GamePackage(manifest)OSS/CDN URL,按 /games/{gameId}/versions/{versionId}/ 版本化(#4)' }
|
||||
manifestUrl: { type: string, description: 'GAP-2 分支A(纯派生,零 DDL):由 packageUrl 同前缀派生 .../manifest.json;宿主先 fetch 并按 checksum 做 sha256 完整性校验后再注入' }
|
||||
entry: { type: string, description: 入口文件相对路径(#4 manifest.entry) }
|
||||
runtimeVersion: { type: string, description: '目标 WanxiangGameSDK / Canvas Runtime 版本(semver,#4 manifest.runtimeVersion)' }
|
||||
preloadPolicy: { type: string, enum: [eager, lazy], description: '预加载策略(#4 manifest.preloadPolicy)' }
|
||||
@ -225,10 +230,13 @@ components:
|
||||
traceId: { type: string, description: '贯穿 生成→编译→加载→运行 的 trace_id(#3 init / #4 provenance)' }
|
||||
SandboxPolicyVO:
|
||||
type: object
|
||||
description: iframe 沙箱隔离策略(T-RT-04),宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14)。
|
||||
description: >-
|
||||
iframe 沙箱隔离策略(T-RT-04),宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14)。
|
||||
【GAP-2 CORS 约束】allowOrigins 同时是 OSS/CDN 取包(manifestUrl/packageUrl)须放行的跨域来源:
|
||||
OSS 桶 CORS 规则 AllowedOrigin 应与本白名单一致(含宿主 origin),AllowedMethod=GET/HEAD、ExposeHeader=ETag,否则宿主跨域 fetch manifest 被拦。
|
||||
properties:
|
||||
sandboxAttr: { type: string, description: "iframe sandbox 属性值(如 'allow-scripts allow-same-origin')" }
|
||||
allowOrigins: { type: array, items: { type: string }, description: 'postMessage 允许的 origin 白名单(宿主侧 #3 双校验)' }
|
||||
allowOrigins: { type: array, items: { type: string }, description: 'postMessage 允许的 origin 白名单(宿主侧 #3 双校验);亦为 OSS/CDN 取包 CORS 须放行的宿主 origin' }
|
||||
|
||||
# ---- 试玩会话开/收(脊柱:play_start/end/时长 → 回灌 telemetry #5)----
|
||||
SessionStartReqVO:
|
||||
|
||||
186
contracts/api-schemas/studio.yaml
Normal file
186
contracts/api-schemas/studio.yaml
Normal file
@ -0,0 +1,186 @@
|
||||
openapi: 3.0.3
|
||||
# 契约 #1 API | 模块:studio(game-module-studio,Wave3 创作主链路最小版)| owner:studio 路
|
||||
# 职责(架构 Doc B §71-76):有状态创作工作台后端。编排「建草稿(委托 project createProject)→一句话生成(委托 aigc)
|
||||
# →进度轮询→重生成→模板应用」。不跑 LLM(委托 aigc)、不持久化项目版本(交 project)、不做锁风裁决(调 compliance)。
|
||||
# 与 game-studio 前端仓区分:本契约是后端 studio 模块(错误码段 112),game-studio 是产品端前端仓名,二者不同。
|
||||
# 端:/app-api(产品端 game-studio,用户 Token);前缀由 yudao 框架按 controller.app 包名自动添加。
|
||||
# 错误码段:studio = 1-112-***-***(R4 治理新登记,本模块独占,禁与他模块重叠:
|
||||
# 100 project/101 aigc/102 runtime/103 feed/104 telemetry/105 pay/106 trade/107 community/108 ip/109 compliance/110 biz/111 ad/112 studio)
|
||||
# 响应统一 Yudao CommonResult 信封:{ code, data, msg };code=0 成功。
|
||||
# 编排链路(MVP 轮询非 MQ,D4):
|
||||
# /template/list (透传 aigc 模板注册表) → /draft (调 project ProjectApi.createProject 拿 gameId upsert 会话)
|
||||
# → /generate (调 aigc AigcApi.submitGenerate 落任务链回填 aigc_task_id) → /task/{id} (透传 aigc 进度轮询)
|
||||
# → /task/{id}/regenerate (调 aigc AigcApi.retryTask 新建 retry_of 任务链)
|
||||
# x-feign-contracts(studio 是调用方,依赖以下对方 -api seam,本契约不对外暴露 Feign):
|
||||
# - project-api ProjectApi.createProject(creatorUserId, title, templateId) → gameId(写类 RPC,显式 creatorUserId,归属校验在 project service)
|
||||
# - aigc-api AigcApi.submitGenerate / getTask / retryTask / getTemplateList(Wave3 新增 seam,纯暴露层委托 AigcTaskService)
|
||||
# attachments(R7):StudioGenerateReqVO.attachments 本波仅持久化(studio 侧落库),不透传 aigc(aigc reqDTO 无 attachments 字段);P1 再透传。
|
||||
# 狠切留 seam 不实现(P1):rig/对白树/可视化批量;资产图仅槽位表(game_studio_asset)+ 单步调度,不六路并发。
|
||||
info:
|
||||
title: 造梦AI studio 模块 API
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
创作主链路最小版("做得出"工作台)。创作者进工作坊建草稿(移交 project 建游戏拿 gameId)→ 一句话生成(委托 aigc)→
|
||||
轮询进度直至终态(透传 aigc)→ 失败可重生成(委托 aigc retryTask)。MVP 轮询非 MQ;前端据此 vite-plugin-mock 自动生成 mock。
|
||||
|
||||
servers:
|
||||
- url: http://localhost:48080
|
||||
description: 本地(Swagger/Knife4j http://localhost:48080/doc.html)
|
||||
|
||||
paths:
|
||||
# ===========================================================================
|
||||
# 产品端 /app-api(创作工作台,用户 Token)
|
||||
# ===========================================================================
|
||||
/app-api/studio/template/list:
|
||||
get:
|
||||
tags: [app-studio]
|
||||
summary: 玩法模板列表(模板浏览/应用 P-TPL-01/03,透传 aigc 模板注册表)
|
||||
description: 透传 aigc 模板注册表(T-AGC-03);MVP 骨架返回空列表占位,待模板注册表接入。
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(玩法模板列表)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultStudioTemplateList' }
|
||||
|
||||
/app-api/studio/draft:
|
||||
post:
|
||||
tags: [app-studio]
|
||||
summary: 建草稿(进工作坊 P-CRT-10 + 草稿移交 project P-TPL-03)
|
||||
description: >-
|
||||
编排:服务端调 project ProjectApi.createProject(creatorUserId=当前登录用户, title, templateId) 建游戏拿 gameId
|
||||
回写会话,再落会话(status=0 草稿)。attachments 仅持久化(R7)。createProject 是硬前置,返回空 gameId 视为移交失败。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/StudioDraftCreateReqVO' }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(新建会话,含回写的 gameId)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultStudioSession' }
|
||||
|
||||
/app-api/studio/generate:
|
||||
post:
|
||||
tags: [app-studio]
|
||||
summary: 一句话生成(P-CRT-01,委托 aigc 提交生成)
|
||||
description: >-
|
||||
编排:校验 session 归属 → 落任务链(status=1 生成中) → 委托 aigc AigcApi.submitGenerate(带 prompt/templateId/gameId/userId)
|
||||
→ 回填 aigc_task_id → 立即返回任务链句柄,前端据 taskChainId 轮询。同步直写无 MQ(D4)。
|
||||
attachments 仅持久化(R7),不入 aigc reqDTO。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/StudioGenerateReqVO' }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(任务链句柄,含 aigc_task_id)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultStudioTaskChain' }
|
||||
|
||||
/app-api/studio/task/{id}:
|
||||
get:
|
||||
tags: [app-studio]
|
||||
summary: 进度轮询(P-CRT-12,透传 aigc 进度/状态)
|
||||
description: >-
|
||||
编排:校验任务链归属 → 委托 aigc AigcApi.getTask 取进度/状态 → 映射链态
|
||||
(aigc succeeded → 链完成回填 version_id + 会话完成;failed/timed_out → 链失败 + 会话失败;其余保持生成中仅回写 progress)。
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: '创作任务链 ID(taskChainId)' }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(任务链最新态,完成后 versionId 非空)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultStudioTaskChain' }
|
||||
|
||||
/app-api/studio/task/{id}/regenerate:
|
||||
post:
|
||||
tags: [app-studio]
|
||||
summary: 重生成迭代(P-CRT-09,委托 aigc retryTask)
|
||||
description: 编排:校验原任务链归属 → 委托 aigc AigcApi.retryTask 重提生成 → 新建一条任务链(retry_of=原 id)回填新 aigc_task_id。
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: '原创作任务链 ID' }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(新建的重试任务链句柄)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultStudioTaskChain' }
|
||||
|
||||
components:
|
||||
schemas:
|
||||
# ========== 请求 VO ==========
|
||||
StudioDraftCreateReqVO:
|
||||
type: object
|
||||
required: [title, templateId]
|
||||
properties:
|
||||
title: { type: string, maxLength: 128, description: '作品标题(透传 project createProject)', example: '我的放置小镇' }
|
||||
templateId: { type: string, description: '玩法模板 ID(透传 project/aigc)', example: 'idle' }
|
||||
prompt: { type: string, maxLength: 1000, description: '一句话 Prompt(建草稿可空,generate 必填)', example: '做一个躲避陨石的小游戏' }
|
||||
attachments: { type: string, description: '附件上下文 JSON(P-CRT-04);本波仅持久化,P1 透传 aigc' }
|
||||
|
||||
StudioGenerateReqVO:
|
||||
type: object
|
||||
required: [sessionId, prompt]
|
||||
properties:
|
||||
sessionId: { type: integer, format: int64, description: '创作会话 ID(建草稿返回的 sessionId)', example: 1024 }
|
||||
prompt: { type: string, maxLength: 1000, description: '一句话 Prompt(透传 aigc submitGenerate)', example: '做一个躲避陨石的小游戏' }
|
||||
attachments: { type: string, description: '附件上下文 JSON(P-CRT-04);【本波仅持久化,P1 透传 aigc】——studio 侧落库持久化,本波不透传 aigc' }
|
||||
|
||||
# ========== 响应 VO ==========
|
||||
StudioTemplateRespVO:
|
||||
type: object
|
||||
description: 玩法模板(透传 aigc 模板注册表,对齐 aigc.yaml TemplateRespVO)
|
||||
properties:
|
||||
templateId: { type: string, description: '模板 ID', example: 'dodge' }
|
||||
name: { type: string, description: '模板名称', example: '躲避' }
|
||||
description: { type: string, description: '模板说明' }
|
||||
coverUrl: { type: string, description: '模板封面/示意图 URL' }
|
||||
examplePrompt: { type: string, description: '示例 Prompt(P-CRT-11)', example: '做一个躲避陨石的小游戏' }
|
||||
|
||||
StudioSessionRespVO:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64, description: '创作会话 ID(sessionId)', example: 1024 }
|
||||
gameId: { type: integer, format: int64, description: '移交后的游戏 ID(project.game_project.id)', example: 2048 }
|
||||
templateId: { type: string, description: '玩法模板 ID', example: 'idle' }
|
||||
title: { type: string, description: '作品标题', example: '我的放置小镇' }
|
||||
prompt: { type: string, description: '一句话 Prompt' }
|
||||
status: { type: integer, description: '会话状态:0草稿 1生成中 2完成 3失败', example: 0 }
|
||||
|
||||
StudioTaskChainRespVO:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64, description: '创作任务链 ID(taskChainId,轮询用)', example: 1024 }
|
||||
sessionId: { type: integer, format: int64, description: '所属创作会话 ID', example: 512 }
|
||||
aigcTaskId: { type: integer, format: int64, description: '委托的 aigc 生成任务 ID', example: 8888 }
|
||||
gameId: { type: integer, format: int64, description: '关联游戏 ID(project.game_project.id)', example: 2048 }
|
||||
versionId: { type: integer, format: int64, description: '产物版本 ID(project.game_version.id);完成后非空', example: 4096 }
|
||||
status: { type: integer, description: '任务链状态:0初始 1生成中 2完成 3失败', example: 1 }
|
||||
progress: { type: integer, description: '进度百分比 0-100(透传 aigc)', example: 60 }
|
||||
retryOf: { type: integer, format: int64, description: '若为重生成,指向原任务链 ID(P-CRT-09 血缘)', example: 1000 }
|
||||
|
||||
# ========== CommonResult 信封 ==========
|
||||
CommonResultStudioTemplateList:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer, example: 0 }
|
||||
data: { type: array, items: { $ref: '#/components/schemas/StudioTemplateRespVO' } }
|
||||
msg: { type: string }
|
||||
CommonResultStudioSession:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer, example: 0 }
|
||||
data: { $ref: '#/components/schemas/StudioSessionRespVO' }
|
||||
msg: { type: string }
|
||||
CommonResultStudioTaskChain:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer, example: 0 }
|
||||
data: { $ref: '#/components/schemas/StudioTaskChainRespVO' }
|
||||
msg: { type: string }
|
||||
92
contracts/db-schemas/V8.0.0__create_game_studio.sql
Normal file
92
contracts/db-schemas/V8.0.0__create_game_studio.sql
Normal file
@ -0,0 +1,92 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 模块:studio(game-module-studio,Wave3 创作主链路最小版)| owner:studio 路
|
||||
-- 文件:V8.0.0__create_game_studio.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V8.0.1 DROP TABLE)
|
||||
-- 内容:studio 模块核心表 —— 创作会话 game_studio_session + 创作任务链 game_studio_taskchain + 资产槽位 game_studio_asset
|
||||
-- 职责(架构 Doc B §71-76):有状态创作工作台后端。编排「建草稿(委托 project createProject)→一句话生成(委托 aigc)
|
||||
-- →进度轮询→重生成→模板应用」。不跑 LLM(委托 aigc)、不持久化项目版本(交 project)、不做锁风裁决(调 compliance)。
|
||||
-- 边界纪律:
|
||||
-- - 项目/版本权威归 project(game_project / game_version);studio 仅持 game_id/version_id 引用,不在本模块建版本表。
|
||||
-- - 生成任务权威归 aigc(game_aigc_task);studio 任务链仅持 aigc_task_id 引用 + 自身链态,不在本模块重建生成状态机。
|
||||
-- - attachments 本波仅持久化(R7),不透传 aigc(aigc reqDTO 无 attachments 字段)。
|
||||
-- - MVP 进度推送走轮询(D4),非 MQ/SSE。
|
||||
-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id;
|
||||
-- 状态机用 tinyint,非法流转由 Service 校验,DO 层不承载;中文列注释;关键查询建索引、禁裸 select*。
|
||||
-- 错误码段:studio = 1-112-***-***
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_studio_session —— 创作会话表(有状态创作工作台主记录,对齐 P-CRT-10 进工作坊)
|
||||
-- 用途:创作者进工作坊 upsert 一条会话,承载草稿入参(title/prompt/templateId/attachments)+ 移交后的 game_id。
|
||||
-- 移交:createDraft 调 project ProjectApi.createProject 拿 gameId 回写本表 game_id(草稿移交 project,P-TPL-03)。
|
||||
-- attachments:JSON 持久化(本波仅持久化不透传 aigc,R7)。
|
||||
-- 状态机 status:0 草稿 → 1 生成中 → 2 完成 / 3 失败(StudioSessionStatusEnum;由 generate/getTask 透传 aigc 终态映射)。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_studio_session` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '创作会话 ID',
|
||||
`creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(业务字段;DataPermission:创作者只见自己会话。区别于审计列 creator)',
|
||||
`game_id` BIGINT NULL DEFAULT NULL COMMENT '移交后的游戏 ID(project.game_project.id);createDraft 调 createProject 后回写',
|
||||
`template_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '玩法模板 ID(透传 aigc,如 dodge/runner/clicker/puzzle/shooter)',
|
||||
`title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '作品标题(建草稿入参,透传 project createProject)',
|
||||
`prompt` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '一句话自然语言 Prompt(透传 aigc submitGenerate)',
|
||||
`attachments` JSON NULL DEFAULT NULL COMMENT '附件上下文(P-CRT-04,JSON 数组);本波仅持久化,P1 透传 aigc',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '会话状态机:0草稿 1生成中 2完成 3失败(StudioSessionStatusEnum;Service 校验流转)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_creator` (`creator_user_id`, `id`) COMMENT '创作者「我的创作会话」列表(DataPermission 边界 + 按 id 倒序)',
|
||||
KEY `idx_game` (`game_id`) COMMENT '按移交后的游戏 ID 反查会话'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作会话表(有状态创作工作台主记录;草稿入参 + 移交 game_id)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_studio_taskchain —— 创作任务链表(线性单步编排,对齐 P-CRT-01/09 一句话生成 + 重生成血缘)
|
||||
-- 用途:generate 时落一条任务链(status=1),回填 aigc_task_id;getTask 轮询透传 aigc 进度/状态映射链态。
|
||||
-- 引用边界:aigc_task_id 引用 aigc.game_aigc_task.id(生成权威归 aigc);version_id 引用 project.game_version.id
|
||||
-- (aigc succeeded 后回填,版本权威归 project);本表只持引用 + 自身链态,不重建生成状态机。
|
||||
-- 重生成血缘:retry_of 指向原任务链 id(P-CRT-09);regenerate 新建一条 retry_of=原 id 的链。
|
||||
-- 链态 status:0 初始 → 1 生成中 → 2 完成(回填 version_id) / 3 失败(透传 aigc failed/timed_out)。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_studio_taskchain` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '创作任务链 ID',
|
||||
`session_id` BIGINT NOT NULL COMMENT '所属创作会话 ID(= game_studio_session.id)',
|
||||
`creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(DataPermission 边界,归属校验用)',
|
||||
`aigc_task_id` BIGINT NULL DEFAULT NULL COMMENT '委托的 aigc 生成任务 ID(= aigc.game_aigc_task.id);generate 后回填,轮询据此查 aigc',
|
||||
`game_id` BIGINT NULL DEFAULT NULL COMMENT '关联游戏 ID(project.game_project.id,透传 aigc 用)',
|
||||
`version_id` BIGINT NULL DEFAULT NULL COMMENT '产物版本 ID(project.game_version.id);aigc succeeded 后由 getTask 回填',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '任务链状态机:0初始 1生成中 2完成 3失败(Service 校验流转,透传 aigc 终态映射)',
|
||||
`progress` INT NOT NULL DEFAULT 0 COMMENT '进度百分比 0-100(透传 aigc progress,供前端进度条 P-CRT-12)',
|
||||
`retry_of` BIGINT NULL DEFAULT NULL COMMENT '若为重生成,指向原任务链 ID(P-CRT-09 重生成血缘)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_session` (`session_id`, `id`) COMMENT '按会话查任务链(含重生成历史,按 id 倒序)',
|
||||
KEY `idx_aigc` (`aigc_task_id`) COMMENT '按 aigc 任务 ID 反查任务链(轮询回写映射)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作任务链表(线性单步编排;持 aigc_task_id/version_id 引用 + 链态)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_studio_asset —— 创作资产槽位表(单步出整包,对齐 P-CRT-02;MVP 仅落槽位 seam,不六路并发)
|
||||
-- 用途:单步生成产物的资产槽位记录(asset_type 槽位 + asset_url + 槽位状态)。
|
||||
-- 狠切(R7/草稿 scope):MVP 只建槽位表占位 seam,不实现六路并发资产生成;资产由 aigc 单步整包产出,studio 仅登记槽位。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_studio_asset` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '资产槽位 ID',
|
||||
`session_id` BIGINT NOT NULL COMMENT '所属创作会话 ID(= game_studio_session.id)',
|
||||
`asset_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '资产槽位类型(如 sprite/background/audio/icon;MVP 槽位占位)',
|
||||
`asset_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '资产 URL(OSS/MinIO;单步整包产出后登记)',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '槽位状态:0未生成 1已生成(Service 校验,MVP 单步直填)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_session_type` (`session_id`, `asset_type`) COMMENT '按会话+槽位类型查资产'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作资产槽位表(单步出整包槽位 seam;MVP 不六路并发)';
|
||||
84
contracts/db-schemas/V9.0.0__create_game_compliance.sql
Normal file
84
contracts/db-schemas/V9.0.0__create_game_compliance.sql
Normal file
@ -0,0 +1,84 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 模块:compliance(game-module-compliance,Wave3 合规域「锁风门 Gate」)| owner:Wave3 compliance 子 agent
|
||||
-- 文件:V9.0.0__create_game_compliance.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V9.0.1 DROP TABLE)
|
||||
-- 内容:compliance 模块三张表 —— 锁风门评估台账 game_compliance_gate_result + 内容分级 game_content_rating + 封禁台账 game_user_ban
|
||||
-- 职责(架构 Doc B + D2 范围铁律):只做 Gate(聚合风格/IP 原子→verdict + 内容分级 + 封禁台账);
|
||||
-- 【明确不建】审核状态机表 / audit_record —— 审核状态机权威归 project(game_review_record),compliance 绝不重写。
|
||||
-- 锁风门 seam(R1):project.submitPublish 同进程调 ComplianceGateApi.evaluate → 聚合裁决落 game_compliance_gate_result + 分级落 game_content_rating
|
||||
-- → 返回 verdict 供 project 据此置 admitted(block→admitted=false 不抛异常)。
|
||||
-- 封禁联动(R3 状态分支):写 game_user_ban 台账(主操作恒执行);下架仅当 project 状态==PUBLISHED 时才调 project reviewProject(3);
|
||||
-- 非 PUBLISHED 只记封禁不调状态机。【本波说明】ProjectApi 暂无 reviewProject seam,下架联动标 TODO 留 Phase C 主 agent 收口,本波仅写台账。
|
||||
-- 降权出流屏蔽:P1 本波不生效(R5,不建 FeedDownweightApi 孤儿 seam)。
|
||||
-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id;
|
||||
-- 状态机用 tinyint,非法流转由 Service 校验,DO 层不承载;中文列注释;关键查询建索引、禁裸 select*。
|
||||
-- 错误码段:compliance = 1-109-***-***
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_compliance_gate_result —— 锁风门评估台账(每次 publish 评估各落一条,审计/溯源用)
|
||||
-- 用途:project.publish 注入 evaluate 时落聚合裁决 + 各原子明细 JSON;admin 复核与合规溯源据此查
|
||||
-- 写口径:非幂等(每次评估各落一条审计记录,不去重);verdict 为聚合结果 max(各原子)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_compliance_gate_result` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '锁风门评估记录 ID',
|
||||
`game_id` BIGINT NOT NULL COMMENT '评估的游戏 ID(project.game_project.id)',
|
||||
`version_id` BIGINT NOT NULL DEFAULT 0 COMMENT '评估的版本 ID(待发布版本;无版本时为 0)',
|
||||
`verdict` VARCHAR(16) NOT NULL DEFAULT 'pass' COMMENT '聚合裁决:pass 通过/review 需人工复核/block 拦截(= max(各原子))',
|
||||
`rating` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '本次评估落定的内容分级:all/8+/12+/16+(可空)',
|
||||
`details` VARCHAR(2048) NOT NULL DEFAULT '' COMMENT '各合规原子明细 JSON([{atom,verdict,reason}],便于复核溯源)',
|
||||
`trace_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '全链路追踪 ID(与 publish 链路对账)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;锁风门评估为 system/发布发起方)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(评估时间)',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_game_version` (`game_id`, `version_id`) COMMENT '按游戏+版本查最近一次锁风门评估(复核/溯源)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '锁风门评估台账(每次 publish 评估落一条,聚合裁决+原子明细;审计/溯源)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_content_rating —— 内容分级表(按游戏+版本唯一;评估时落、admin 复核可改)
|
||||
-- 用途:/app-api/compliance/rating/get 读分级;分级写入仅由 Gate evaluate 落定 + admin 复核产生
|
||||
-- 唯一键:(game_id,version_id) 业务唯一(含 deleted/tenant_id 适配逻辑删+多租户),同游戏同版本只存一条最新分级
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_content_rating` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '内容分级记录 ID',
|
||||
`game_id` BIGINT NOT NULL COMMENT '游戏 ID(project.game_project.id)',
|
||||
`version_id` BIGINT NOT NULL DEFAULT 0 COMMENT '版本 ID(无版本时为 0)',
|
||||
`rating` VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '内容分级:all 全年龄/8+/12+/16+',
|
||||
`rated_by` VARCHAR(32) NOT NULL DEFAULT 'gate' COMMENT '分级来源:gate 锁风门自动/admin 人工复核',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(admin 复核改分级时记录)',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_game_version` (`game_id`, `version_id`, `deleted`, `tenant_id`) COMMENT '游戏+版本分级业务唯一键(含 deleted/tenant_id 适配逻辑删+多租户)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '内容分级表(评估落定+admin 复核;/app-api 只读取)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_user_ban —— 封禁台账(封禁/降权的权威台账;主操作恒执行)
|
||||
-- 用途:admin 封禁游戏/用户落台账(恒执行);R3 下架联动仅当游戏==PUBLISHED 时才调 project 状态机(本波标 TODO);
|
||||
-- R5 降权出流屏蔽 P1 本波不生效(不建 feed seam)
|
||||
-- 唯一键:(target_type,target_id,status) 生效中唯一(status=1 生效,含 deleted/tenant_id),同目标不可重复生效封禁;解封后(status=0)可再次封禁
|
||||
-- 状态机 status:1 生效 → 0 已解封(解封由 admin 触发,Service 校验合法流转)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_user_ban` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '封禁台账记录 ID',
|
||||
`target_type` TINYINT NOT NULL COMMENT '封禁目标类型:1 游戏 / 2 用户',
|
||||
`target_id` BIGINT NOT NULL COMMENT '封禁目标 ID(游戏=game_id / 用户=user_id)',
|
||||
`ban_type` TINYINT NOT NULL DEFAULT 1 COMMENT '处置类型:1 封禁(下架/禁用)/ 2 降权(出流屏蔽,P1 本波不生效)',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '封禁状态机:1 生效 / 0 已解封(合法流转由 Service 校验)',
|
||||
`reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封禁/降权原因(合规处置说明)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;封禁操作的 admin userId)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(封禁时间)',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(解封操作的 admin userId)',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_target_active` (`target_type`, `target_id`, `status`, `deleted`, `tenant_id`) COMMENT '生效中封禁业务唯一键:同目标 status=1 不可重复(含 deleted/tenant_id 适配逻辑删+多租户)',
|
||||
KEY `idx_type_status` (`target_type`, `status`) COMMENT 'admin 按目标类型+状态分页查封禁台账'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '封禁台账表(封禁/降权权威台账;主操作恒执行,下架联动按 project 状态分支)';
|
||||
@ -0,0 +1,96 @@
|
||||
package cn.wanxiang.game.module.aigc.api;
|
||||
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcGenerateReqDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcGenerateRespDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO;
|
||||
import cn.wanxiang.game.module.aigc.enums.ApiConstants;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* RPC 服务 - AI 生成 API(aigc → studio seam,Wave3 新增)
|
||||
*
|
||||
* 用途:studio 创作主链路(一句话生成/进度轮询/重生成/模板浏览)委托 aigc 既有 {@code AigcTaskService}。
|
||||
* 设计纪律:本 seam 为纯暴露层(@RestController @Primary 委托 service,不改 service),删除后 aigc 既有单测仍绿。
|
||||
* 跨模块只依赖 aigc 的 -api(cn.wanxiang.game.module.aigc.api/.dto),禁止 studio 依赖 aigc 的 -server,避免编译耦合。
|
||||
*
|
||||
* MVP 单体:同进程内由 {@code AigcApiImpl}(@RestController @Primary) 就地解析(仿 yudao DictDataApi);
|
||||
* 拆微服务后按 {@link ApiConstants#NAME} 走真实 Feign。
|
||||
*
|
||||
* 写类 RPC(submitGenerate/getTask/retryTask)无登录上下文:userId 由调用方(studio 受信边界)显式传入,
|
||||
* 不从匿名上下文推断、不裸暴露给前端;service 层据此做数据归属校验(创作者只见自己任务)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@FeignClient(name = ApiConstants.NAME)
|
||||
@Tag(name = "RPC 服务 - AI 生成")
|
||||
public interface AigcApi {
|
||||
|
||||
String PREFIX = ApiConstants.PREFIX;
|
||||
|
||||
/**
|
||||
* 玩法模板列表(studio 模板浏览/应用 P-TPL-01/03 透传 aigc 模板注册表)
|
||||
*
|
||||
* 只读、幂等;委托 {@code AigcTaskService.getTemplateList}(MVP 骨架返回空列表占位,待模板注册表接入)。
|
||||
*
|
||||
* @return 启用的模板列表(CommonResult 包裹)
|
||||
*/
|
||||
@GetMapping(PREFIX + "/template/list")
|
||||
@Operation(summary = "玩法模板列表(studio 透传)")
|
||||
CommonResult<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/traceId;CommonResult 包裹)
|
||||
*/
|
||||
@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/failureReason;CommonResult 包裹)
|
||||
*/
|
||||
@GetMapping(PREFIX + "/get-task")
|
||||
@Operation(summary = "轮询生成任务状态(studio 透传)")
|
||||
@Parameter(name = "taskId", description = "生成任务 ID", required = true, example = "1024")
|
||||
@Parameter(name = "userId", description = "当前登录创作者 ID", required = true, example = "99")
|
||||
CommonResult<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/traceId;CommonResult 包裹)
|
||||
*/
|
||||
@PostMapping(PREFIX + "/retry-task")
|
||||
@Operation(summary = "重试生成任务(studio 委托重生成)")
|
||||
@Parameter(name = "taskId", description = "原生成任务 ID", required = true, example = "1024")
|
||||
@Parameter(name = "userId", description = "当前登录创作者 ID", required = true, example = "99")
|
||||
CommonResult<AigcGenerateRespDTO> retryTask(@RequestParam("taskId") Long taskId,
|
||||
@RequestParam("userId") Long userId);
|
||||
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package cn.wanxiang.game.module.aigc.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 提交生成任务跨模块入参(studio → aigc,对应 aigc-server AigcGenerateReqVO 的 RPC 镜像)
|
||||
*
|
||||
* 用途:studio 创作编排「一句话生成」(P-CRT-01) 委托 aigc 提交生成任务。
|
||||
* 写类 RPC 无登录上下文:userId 由调用方(studio service 受信边界)显式传入,不从匿名上下文推断、不裸暴露给前端。
|
||||
* 注意(R7 attachments 持久化-only):本 DTO 不含 attachments 字段——studio 侧 attachments 仅落库持久化、本波不透传 aigc;
|
||||
* P1 若需透传,再 additive 给本 DTO 加字段,不在本波私造。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Data
|
||||
public class AigcGenerateReqDTO {
|
||||
|
||||
/**
|
||||
* 当前登录创作者 ID(写类 RPC 显式入参,由 studio 受信边界传入)
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
* 创作者一句话自然语言 Prompt(→ aigc 契约#6 input.prompt)
|
||||
*/
|
||||
private String prompt;
|
||||
/**
|
||||
* 玩法模板 ID(→ aigc 契约#6 input.templateId;空则由 Dify 意图解析匹配)
|
||||
*/
|
||||
private String templateId;
|
||||
/**
|
||||
* 关联游戏 ID(project.game_project.id);studio 草稿移交后回填,产物经此落 game_version
|
||||
*/
|
||||
private Long gameId;
|
||||
/**
|
||||
* 关联版本 ID(project.game_version.id);指定则产物写入该版本,为空时由生成流程新建版本
|
||||
*/
|
||||
private Long versionId;
|
||||
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package cn.wanxiang.game.module.aigc.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 提交/重试生成任务跨模块返回(aigc → studio,对应 aigc-server GenerateRespVO 的 RPC 镜像)
|
||||
*
|
||||
* 异步语义:提交/重试立即返回 taskId(status=0 queued),studio 据 taskId 轮询直至终态(getTask)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Data
|
||||
public class AigcGenerateRespDTO {
|
||||
|
||||
/**
|
||||
* 生成任务 ID(= 对外 taskId,studio 回填 taskchain.aigc_task_id 后轮询用)
|
||||
*/
|
||||
private Long taskId;
|
||||
/**
|
||||
* 初始状态:0 queued(对齐 AigcTaskStatusEnum)
|
||||
*/
|
||||
private Integer status;
|
||||
/**
|
||||
* 贯穿全链路 traceId(透传给 studio/日志关联)
|
||||
*/
|
||||
private String traceId;
|
||||
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package cn.wanxiang.game.module.aigc.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 生成任务状态跨模块返回(aigc → studio,对应 aigc-server AigcTaskRespVO/AigcTaskDO 的 RPC 镜像)
|
||||
*
|
||||
* 用途:studio 进度轮询 (P-CRT-12) 透传 aigc 任务进度/状态/产物版本,映射为 studio 任务链态:
|
||||
* aigc succeeded(2) → 链完成回填 version_id;failed(3)/timed_out(4) → 链失败。
|
||||
* 字段为 studio 编排所需的最小集(status/progress/versionId/failureReason/traceId),不全量镜像 AigcTaskDO。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Data
|
||||
public class AigcTaskRespDTO {
|
||||
|
||||
/**
|
||||
* 生成任务 ID(= taskId)
|
||||
*/
|
||||
private Long taskId;
|
||||
/**
|
||||
* 状态机:0 queued 1 running 2 succeeded 3 failed 4 timed_out 5 canceled(对齐 AigcTaskStatusEnum)
|
||||
*/
|
||||
private Integer status;
|
||||
/**
|
||||
* 进度百分比 0-100(studio 透传给前端进度条)
|
||||
*/
|
||||
private Integer progress;
|
||||
/**
|
||||
* 产物落地的版本 ID(project.game_version.id);status=2 succeeded 后非空,studio 据此回填任务链 version_id
|
||||
*/
|
||||
private Long versionId;
|
||||
/**
|
||||
* 失败原因分类(status=3/4 时非空)
|
||||
*/
|
||||
private String failureReason;
|
||||
/**
|
||||
* 贯穿全链路 traceId
|
||||
*/
|
||||
private String traceId;
|
||||
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package cn.wanxiang.game.module.aigc.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 玩法模板跨模块传输对象(aigc → studio,对应 aigc.yaml TemplateRespVO 的 RPC 镜像)
|
||||
*
|
||||
* 用途:studio 创作工作台「模板浏览/应用」(P-TPL-01/03) 透传 aigc 模板注册表(T-AGC-03)。
|
||||
* 字段对齐 aigc-server 的 {@code TemplateRespVO}(不多不少),由 {@code AigcApiImpl} 转换后跨模块返回。
|
||||
* 跨模块只依赖 aigc 的 -api(cn.wanxiang.game.module.aigc.dto),禁止 studio 依赖 aigc 的 -server。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Data
|
||||
public class AigcTemplateDTO {
|
||||
|
||||
/**
|
||||
* 模板 ID(如 dodge/runner/clicker/puzzle/shooter)
|
||||
*/
|
||||
private String templateId;
|
||||
/**
|
||||
* 模板名称
|
||||
*/
|
||||
private String name;
|
||||
/**
|
||||
* 模板说明
|
||||
*/
|
||||
private String description;
|
||||
/**
|
||||
* 模板封面/示意图 URL
|
||||
*/
|
||||
private String coverUrl;
|
||||
/**
|
||||
* 示例 Prompt(P-CRT-11 示例 Prompt 库)
|
||||
*/
|
||||
private String examplePrompt;
|
||||
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package cn.wanxiang.game.module.aigc.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.RpcConstants;
|
||||
|
||||
/**
|
||||
* aigc 模块 API 相关枚举(Feign 服务名 + RPC 前缀)
|
||||
*
|
||||
* 仿 yudao system / project / ad 的 {@code ApiConstants} 风格:aigc 此前仅含枚举无 Feign 契约,
|
||||
* Wave3 因 studio 创作主链路需委托 aigc 提交生成,故本类与 {@code AigcApi} seam 一并新增(additive,只暴露不改 service)。
|
||||
* - NAME:Feign 目标服务名。MVP 为单体(spring.application.name=yudao-server,所有模块编入一个 JAR),
|
||||
* 同进程内调用由 {@code AigcApiImpl}(@RestController @Primary) 就地解析(与 yudao DictDataApi 同构);
|
||||
* 拆微服务后该值即对应 aigc 服务的 spring.application.name。
|
||||
* - PREFIX:RPC 接口前缀 /rpc-api/aigc,与业务端 /app-api、/admin-api 隔离,避免与业务路由冲突。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public class ApiConstants {
|
||||
|
||||
/**
|
||||
* 服务名(拆微服务后需与 aigc 服务的 spring.application.name 一致;MVP 单体下仅作 Feign 客户端名占位)
|
||||
*/
|
||||
public static final String NAME = "aigc-server";
|
||||
|
||||
/**
|
||||
* RPC API 前缀(/rpc-api/aigc)
|
||||
*/
|
||||
public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/aigc";
|
||||
|
||||
/**
|
||||
* 接口版本
|
||||
*/
|
||||
public static final String VERSION = "1.0.0";
|
||||
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
package cn.wanxiang.game.module.aigc.api;
|
||||
|
||||
import cn.wanxiang.game.module.aigc.controller.app.task.vo.AigcGenerateReqVO;
|
||||
import cn.wanxiang.game.module.aigc.controller.app.task.vo.TemplateRespVO;
|
||||
import cn.wanxiang.game.module.aigc.dal.dataobject.task.AigcTaskDO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcGenerateReqDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcGenerateRespDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO;
|
||||
import cn.wanxiang.game.module.aigc.service.task.AigcTaskService;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* AI 生成 API 实现(提供 RESTful 接口给 studio Feign 调用,对应 Wave3 aigc → studio seam)
|
||||
*
|
||||
* @RestController + @Primary:与 yudao DictDataApiImpl 同构——单体内同进程调用走本实现,拆微服务后走 Feign。
|
||||
* 纯暴露层:仅做 DTO↔VO/DO 转换并委托既有 {@link AigcTaskService},不写任何业务逻辑(业务在 service,不改)。
|
||||
* 删除本类后 aigc 既有单测仍绿(service 零改动),即「只暴露不改」回滚门。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@RestController // 提供 RESTful API 接口,给 Feign 调用
|
||||
@Validated
|
||||
@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析)
|
||||
public class AigcApiImpl implements AigcApi {
|
||||
|
||||
@Resource
|
||||
private AigcTaskService aigcTaskService;
|
||||
|
||||
@Override
|
||||
public CommonResult<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 → 既有 AigcGenerateReqVO(attachments 不在 req,本波不透传 aigc,R7);userId 取显式入参
|
||||
AigcGenerateReqVO reqVO = new AigcGenerateReqVO();
|
||||
reqVO.setPrompt(req.getPrompt());
|
||||
reqVO.setTemplateId(req.getTemplateId());
|
||||
reqVO.setGameId(req.getGameId());
|
||||
reqVO.setVersionId(req.getVersionId());
|
||||
AigcTaskDO task = aigcTaskService.submitGenerate(reqVO, req.getUserId());
|
||||
return success(toGenerateRespDTO(task));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<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 → 提交/重试返回 DTO(taskId/status/traceId)
|
||||
*
|
||||
* @param task 新建/重试任务 DO
|
||||
* @return 任务句柄 DTO
|
||||
*/
|
||||
private AigcGenerateRespDTO toGenerateRespDTO(AigcTaskDO task) {
|
||||
AigcGenerateRespDTO dto = new AigcGenerateRespDTO();
|
||||
dto.setTaskId(task.getId());
|
||||
dto.setStatus(task.getStatus());
|
||||
dto.setTraceId(task.getTraceId());
|
||||
return dto;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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/Feign(ComplianceGateApi 暴露给 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>
|
||||
@ -0,0 +1,45 @@
|
||||
package cn.wanxiang.game.module.compliance.api;
|
||||
|
||||
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
|
||||
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
||||
import cn.wanxiang.game.module.compliance.enums.ApiConstants;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
/**
|
||||
* RPC 服务 - 合规锁风门 Gate API(compliance → project.publish seam)
|
||||
*
|
||||
* 对应契约 compliance.yaml:compliance 通过本 -api 暴露给 project.publish 注入的唯一 seam(R1)。
|
||||
* 用途:project.submitPublish 在发布前同步调 evaluate,据返回 verdict 映射为 key=compliance 的 GateResultVO
|
||||
* (block → admitted=false 不抛异常;pass/review → 进 REVIEWING)。注入逻辑由主 agent 在 GAP-1 串行写,compliance 只交付本 seam。
|
||||
* 跨模块只依赖 compliance 的 -api(cn.wanxiang.game.module.compliance.api),禁止 project 依赖 compliance 的 -server。
|
||||
*
|
||||
* MVP 单体:同进程内由 {@code ComplianceGateApiImpl}(@RestController @Primary) 就地解析(仿 yudao DictDataApi);
|
||||
* 拆微服务后按 {@link ApiConstants#NAME} 走真实 Feign。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@FeignClient(name = ApiConstants.NAME)
|
||||
@Tag(name = "RPC 服务 - 合规锁风门 Gate")
|
||||
public interface ComplianceGateApi {
|
||||
|
||||
String PREFIX = ApiConstants.PREFIX;
|
||||
|
||||
/**
|
||||
* 锁风门评估:聚合风格/IP 合规原子产出 verdict(供 project.publish 同进程注入)
|
||||
*
|
||||
* 聚合语义:verdict = max(各原子),block > review > pass;同时落 game_compliance_gate_result 台账与 game_content_rating 分级。
|
||||
* 同步、非幂等写台账(每次发布评估各落一条审计记录,供溯源)。block 仍正常返回 verdict(不抛异常,由 project 侧据此置 admitted=false)。
|
||||
*
|
||||
* @param reqDTO 评估入参(gameId/versionId 必填,title/summary/ageRating/promptHash 供原子参考)
|
||||
* @return 聚合裁决(CommonResult 包裹)
|
||||
*/
|
||||
@PostMapping(PREFIX + "/evaluate")
|
||||
@Operation(summary = "锁风门评估(project.publish 注入用)")
|
||||
CommonResult<GateVerdict> evaluate(@RequestBody ComplianceGateReqDTO reqDTO);
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package cn.wanxiang.game.module.compliance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 锁风门评估入参(project.publish → compliance seam,对应 compliance.yaml ComplianceGateReqDTO)
|
||||
*
|
||||
* project.submitPublish 注入 ComplianceGateApi.evaluate 时传入;compliance 聚合各风格/IP 原子裁决后返回 {@link GateVerdict}。
|
||||
* gameId/versionId 为定位锚点(落 game_compliance_gate_result);title/summary/ageRating/promptHash 供风格原子参考(MVP 桩默认 pass)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Data
|
||||
public class ComplianceGateReqDTO {
|
||||
|
||||
/**
|
||||
* 游戏项目 ID(game_project.id;锁风门定位锚点)
|
||||
*/
|
||||
private Long gameId;
|
||||
/**
|
||||
* 待发布版本 ID(落 game_compliance_gate_result.version_id)
|
||||
*/
|
||||
private Long versionId;
|
||||
/**
|
||||
* 标题(风格内容原子参考;可空)
|
||||
*/
|
||||
private String title;
|
||||
/**
|
||||
* 摘要/简介(风格内容原子参考;可空)
|
||||
*/
|
||||
private String summary;
|
||||
/**
|
||||
* 适龄分级建议:all/8+/12+/16+(落 game_content_rating.rating;可空)
|
||||
*/
|
||||
private String ageRating;
|
||||
/**
|
||||
* 生成 Prompt 摘要哈希(合规溯源用;可空)
|
||||
*/
|
||||
private String promptHash;
|
||||
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package cn.wanxiang.game.module.compliance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 锁风门聚合裁决(compliance → project.publish seam 出参,对应 compliance.yaml GateVerdict)
|
||||
*
|
||||
* = 草案 ComplianceGateRespDTO(R1 命名对齐为 GateVerdict)。
|
||||
* verdict 为聚合结果 = max(各原子),block > review > pass;detail 为各原子明细;rating 为本次评估落定的内容分级。
|
||||
* project.submitPublish 据 verdict 映射为 key=compliance 的 GateResultVO(R1 唯一注入点)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Data
|
||||
public class GateVerdict {
|
||||
|
||||
/**
|
||||
* 聚合裁决:pass/review/block(与 ComplianceVerdictEnum 编码一致)
|
||||
*/
|
||||
private String verdict;
|
||||
/**
|
||||
* 内容分级:all/8+/12+/16+(本次评估落定,可空)
|
||||
*/
|
||||
private String rating;
|
||||
/**
|
||||
* 各合规原子明细(风格/图像/IP 逐条裁决)
|
||||
*/
|
||||
private List<ComplianceGateItemDTO> detail;
|
||||
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package cn.wanxiang.game.module.compliance.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.RpcConstants;
|
||||
|
||||
/**
|
||||
* compliance 模块 API 相关枚举(Feign 服务名 + RPC 前缀)
|
||||
*
|
||||
* 仿 yudao system 的 {@code ApiConstants} 风格(克隆 ad 黄金模板)。
|
||||
* - NAME:Feign 目标服务名。MVP 为单体(spring.application.name=yudao-server,所有模块编入一个 JAR),
|
||||
* 同进程内调用由本模块 {@code ComplianceGateApiImpl}(@RestController @Primary) 就地解析(与 yudao DictDataApi 同构);
|
||||
* 拆分微服务后该值即对应 compliance 服务的 spring.application.name。
|
||||
* - PREFIX:RPC 接口前缀 /rpc-api/compliance,与业务端 /app-api、/admin-api 隔离,避免与业务路由冲突。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public class ApiConstants {
|
||||
|
||||
/**
|
||||
* 服务名(拆微服务后需与 compliance 服务的 spring.application.name 一致;MVP 单体下仅作 Feign 客户端名占位)
|
||||
*/
|
||||
public static final String NAME = "compliance-server";
|
||||
|
||||
/**
|
||||
* RPC API 前缀(/rpc-api/compliance)
|
||||
*/
|
||||
public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/compliance";
|
||||
|
||||
/**
|
||||
* 接口版本
|
||||
*/
|
||||
public static final String VERSION = "1.0.0";
|
||||
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package cn.wanxiang.game.module.compliance.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 封禁状态机枚举(对应表 game_user_ban.status,tinyint)
|
||||
*
|
||||
* 合法流转:1 生效 → 0 已解封(解封由 admin 触发)。合法性由 Service 校验,DO 层不承载。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum BanStatusEnum {
|
||||
|
||||
CANCELLED(0, "已解封"),
|
||||
ACTIVE(1, "生效");
|
||||
|
||||
/** 状态值(落库 tinyint) */
|
||||
private final Integer status;
|
||||
/** 状态名(展示用) */
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 是否生效中
|
||||
*
|
||||
* @param status 待判断状态值
|
||||
* @return true=生效中
|
||||
*/
|
||||
public static boolean isActive(Integer status) {
|
||||
return Objects.equals(ACTIVE.status, status);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package cn.wanxiang.game.module.compliance.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 封禁目标类型枚举(对应表 game_user_ban.target_type,tinyint)
|
||||
*
|
||||
* 1 游戏(target_id=game_id,封禁可联动下架)/ 2 用户(target_id=user_id)。
|
||||
* R3:仅 GAME 类型且 game.status==PUBLISHED 时才联动 project 下架状态机(本波标 TODO)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum BanTargetTypeEnum {
|
||||
|
||||
GAME(1, "游戏"),
|
||||
USER(2, "用户");
|
||||
|
||||
/** 类型值(落库 tinyint) */
|
||||
private final Integer type;
|
||||
/** 类型名(展示用) */
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 是否为游戏目标(仅游戏目标可联动下架)
|
||||
*
|
||||
* @param type 待判断类型值
|
||||
* @return true=游戏
|
||||
*/
|
||||
public static boolean isGame(Integer type) {
|
||||
return Objects.equals(GAME.type, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验类型值是否合法(仅允许 1 游戏 / 2 用户)
|
||||
*
|
||||
* @param type 待判断类型值
|
||||
* @return true=合法
|
||||
*/
|
||||
public static boolean isValid(Integer type) {
|
||||
return Objects.equals(GAME.type, type) || Objects.equals(USER.type, type);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package cn.wanxiang.game.module.compliance.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 合规裁决枚举(锁风门 Gate 聚合结果)
|
||||
*
|
||||
* 三态:pass 通过 / review 需人工复核 / block 拦截。
|
||||
* 聚合语义(Service 固化、可单测):verdict = max(各原子),严重度 block(2) > review(1) > pass(0)。
|
||||
* 在 project.publish 注入时映射(R1):block → 该 gate pass=false + admitted=false(不抛异常);pass/review → gate pass=true 进 REVIEWING。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ComplianceVerdictEnum {
|
||||
|
||||
PASS("pass", 0, "通过"),
|
||||
REVIEW("review", 1, "需人工复核"),
|
||||
BLOCK("block", 2, "拦截");
|
||||
|
||||
/** 裁决编码(落库 game_compliance_gate_result.verdict / 契约 GateVerdict.verdict) */
|
||||
private final String verdict;
|
||||
/** 严重度(聚合 max 用,越大越严重) */
|
||||
private final Integer severity;
|
||||
/** 裁决名(展示用) */
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 两裁决取更严重者(聚合用,block > review > pass)
|
||||
*
|
||||
* @param a 裁决 A(可空,空视为 PASS)
|
||||
* @param b 裁决 B(可空,空视为 PASS)
|
||||
* @return 更严重的裁决
|
||||
*/
|
||||
public static ComplianceVerdictEnum max(ComplianceVerdictEnum a, ComplianceVerdictEnum b) {
|
||||
ComplianceVerdictEnum x = a == null ? PASS : a;
|
||||
ComplianceVerdictEnum y = b == null ? PASS : b;
|
||||
return x.severity >= y.severity ? x : y;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按编码查枚举
|
||||
*
|
||||
* @param verdict 裁决编码
|
||||
* @return 枚举;非法值返回 null
|
||||
*/
|
||||
public static ComplianceVerdictEnum of(String verdict) {
|
||||
for (ComplianceVerdictEnum e : values()) {
|
||||
if (Objects.equals(e.verdict, verdict)) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为拦截(block)
|
||||
*
|
||||
* @param verdict 裁决编码
|
||||
* @return true=block
|
||||
*/
|
||||
public static boolean isBlock(String verdict) {
|
||||
return Objects.equals(BLOCK.verdict, verdict);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package cn.wanxiang.game.module.compliance.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
|
||||
|
||||
/**
|
||||
* compliance 模块错误码枚举类
|
||||
*
|
||||
* compliance 模块独占 1-109-***-*** 段(见 contracts/api-schemas/compliance.yaml 头部 + contracts/README.md §4 段位表)。
|
||||
* 约定:禁止与其它模块错误码段重叠(100 project/101 aigc/102 runtime/103 feed/104 telemetry/106 trade/109 compliance/111 ad/112 studio);新增错误码在此登记。
|
||||
*
|
||||
* 注意(R1):publish-blocked(发布被门禁拦截)错误码不在本类登记——block 时 admitted=false 不抛异常,
|
||||
* 该文案由 project 侧 GateResultVO.code 携带;compliance 不重复登记 publish-blocked。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public interface ErrorCodeConstants {
|
||||
|
||||
// ========== 锁风门 Gate 1-109-001-*** ==========
|
||||
/** 锁风门:评估目标(游戏/版本)不存在 */
|
||||
ErrorCode COMPLIANCE_GATE_TARGET_NOT_FOUND = new ErrorCode(1_109_001_000, "锁风门:游戏/版本不存在");
|
||||
|
||||
// ========== 封禁台账 1-109-002-*** ==========
|
||||
/** 封禁:目标类型非法(仅允许 1 游戏 / 2 用户) */
|
||||
ErrorCode COMPLIANCE_BAN_TARGET_INVALID = new ErrorCode(1_109_002_000, "封禁:目标类型非法");
|
||||
/** 封禁:该目标已存在生效中的封禁记录(uk_target_active 查重) */
|
||||
ErrorCode COMPLIANCE_BAN_ALREADY_EXISTS = new ErrorCode(1_109_002_001, "封禁:该目标已被封禁");
|
||||
/** 解封:封禁记录不存在(按 banId 查不到或已解封) */
|
||||
ErrorCode COMPLIANCE_BAN_NOT_EXISTS = new ErrorCode(1_109_002_002, "解封:封禁记录不存在");
|
||||
|
||||
// ========== 内容分级 1-109-003-*** ==========
|
||||
/** 分级:无分级记录(按 game_id+version_id 查不到) */
|
||||
ErrorCode COMPLIANCE_RATING_NOT_FOUND = new ErrorCode(1_109_003_000, "分级:无分级记录");
|
||||
|
||||
}
|
||||
@ -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 的 -api:R3 封禁下架联动仅当 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>
|
||||
|
||||
<!-- DB:MyBatis Plus(BaseMapperX / 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>
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package cn.wanxiang.game.module.compliance.controller.admin.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 解封入参 VO(admin-api,对齐 compliance.yaml /admin-api/compliance/ban/cancel)
|
||||
*
|
||||
* 按 banId 解封:status 1→0(合法流转由 Service 校验,记录不存在/已解封报 COMPLIANCE_BAN_NOT_EXISTS)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "管理后台 - 解封 Request VO")
|
||||
@Data
|
||||
public class BanCancelReqVO {
|
||||
|
||||
@Schema(description = "封禁台账记录 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "封禁记录 ID 不能为空")
|
||||
private Long banId;
|
||||
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package cn.wanxiang.game.module.compliance.controller.admin.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 封禁创建入参 VO(admin-api,对齐 compliance.yaml /admin-api/compliance/ban/create)
|
||||
*
|
||||
* 落 game_user_ban 台账(主操作恒执行);R3 下架联动仅当 targetType=游戏 且 game.status==PUBLISHED 时才调 project 状态机(本波标 TODO)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "管理后台 - 封禁创建 Request VO")
|
||||
@Data
|
||||
public class BanCreateReqVO {
|
||||
|
||||
@Schema(description = "封禁目标类型:1 游戏 / 2 用户", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "封禁目标类型不能为空")
|
||||
private Integer targetType;
|
||||
|
||||
@Schema(description = "封禁目标 ID(游戏=game_id / 用户=user_id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
@NotNull(message = "封禁目标 ID 不能为空")
|
||||
private Long targetId;
|
||||
|
||||
@Schema(description = "处置类型:1 封禁 / 2 降权(P1 本波不生效)", example = "1")
|
||||
private Integer banType;
|
||||
|
||||
@Schema(description = "封禁/降权原因", example = "违规内容,违反社区规范")
|
||||
private String reason;
|
||||
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package cn.wanxiang.game.module.compliance.controller.admin.vo;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 封禁台账分页 Request VO(admin,对齐 compliance.yaml /admin-api/compliance/ban/page)
|
||||
*
|
||||
* 分页参数复用 Yudao PageParam(pageNo/pageSize);可选按目标类型/状态筛选(命中 idx_type_status)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "管理后台 - 封禁台账分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class BanPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "封禁目标类型过滤:1 游戏 / 2 用户", example = "1")
|
||||
private Integer targetType;
|
||||
|
||||
@Schema(description = "封禁状态过滤:1 生效 / 0 已解封", example = "1")
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package cn.wanxiang.game.module.compliance.controller.admin.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 封禁台账 Response VO(admin,对齐 compliance.yaml /admin-api/compliance/ban/page 项)
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "管理后台 - 封禁台账 Response VO")
|
||||
@Data
|
||||
public class UserBanRespVO {
|
||||
|
||||
@Schema(description = "封禁台账记录 ID", example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "封禁目标类型:1 游戏 / 2 用户", example = "1")
|
||||
private Integer targetType;
|
||||
|
||||
@Schema(description = "封禁目标 ID", example = "1024")
|
||||
private Long targetId;
|
||||
|
||||
@Schema(description = "处置类型:1 封禁 / 2 降权", example = "1")
|
||||
private Integer banType;
|
||||
|
||||
@Schema(description = "封禁状态:1 生效 / 0 已解封", example = "1")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "封禁/降权原因", example = "违规内容")
|
||||
private String reason;
|
||||
|
||||
@Schema(description = "封禁时间", example = "2026-06-09 10:00:00")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package cn.wanxiang.game.module.compliance.controller.app.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 内容分级 Response VO(app-api 只读,对齐 compliance.yaml /app-api/compliance/rating/get)
|
||||
*
|
||||
* 产品端据此展示游戏适龄分级(all/8+/12+/16+)。分级写入仅由 Gate evaluate 落定 + admin 复核产生。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "用户 App - 内容分级 Response VO")
|
||||
@Data
|
||||
public class ContentRatingRespVO {
|
||||
|
||||
@Schema(description = "内容分级:all 全年龄/8+/12+/16+", example = "all")
|
||||
private String rating;
|
||||
|
||||
@Schema(description = "分级来源:gate 锁风门自动/admin 人工复核", example = "gate")
|
||||
private String ratedBy;
|
||||
|
||||
@Schema(description = "分级更新时间", example = "2026-06-09 10:00:00")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package cn.wanxiang.game.module.compliance.dal.dataobject.ban;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 封禁台账 DO(对应表 game_user_ban)
|
||||
*
|
||||
* 继承 {@link TenantBaseDO}:自动携带审计/租户列(creator=封禁操作的 admin userId)。
|
||||
* 唯一键 uk_target_active=(target_type,target_id,status,deleted,tenant_id):同目标 status=1 不可重复生效封禁。
|
||||
* 状态机 status:1 生效 → 0 已解封(合法流转由 Service 校验,DO 层不承载)。
|
||||
* R3:封禁写台账=主操作恒执行;下架联动按 project 状态分支(仅 PUBLISHED 调状态机,本波标 TODO)。
|
||||
* R5:ban_type=2 降权出流屏蔽 P1 本波不生效(不建 feed seam)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@TableName("game_user_ban")
|
||||
@KeySequence("game_user_ban_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class UserBanDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 封禁台账记录 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 封禁目标类型:1 游戏 / 2 用户
|
||||
*/
|
||||
private Integer targetType;
|
||||
/**
|
||||
* 封禁目标 ID(游戏=game_id / 用户=user_id)
|
||||
*/
|
||||
private Long targetId;
|
||||
/**
|
||||
* 处置类型:1 封禁(下架/禁用)/ 2 降权(出流屏蔽,P1 本波不生效)
|
||||
*/
|
||||
private Integer banType;
|
||||
/**
|
||||
* 封禁状态机:1 生效 / 0 已解封(合法流转由 Service 校验)
|
||||
*/
|
||||
private Integer status;
|
||||
/**
|
||||
* 封禁/降权原因(合规处置说明)
|
||||
*/
|
||||
private String reason;
|
||||
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
package cn.wanxiang.game.module.compliance.dal.dataobject.gate;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 锁风门评估台账 DO(对应表 game_compliance_gate_result)
|
||||
*
|
||||
* 继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列。
|
||||
* 写口径:每次 publish 评估各落一条(非幂等审计记录);verdict 为聚合结果 max(各原子),details 为各原子明细 JSON。
|
||||
* 裁决合法性/聚合逻辑由 Service 承载,DO 层只承载数据。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@TableName("game_compliance_gate_result")
|
||||
@KeySequence("game_compliance_gate_result_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class GateResultDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 锁风门评估记录 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 评估的游戏 ID(project.game_project.id)
|
||||
*/
|
||||
private Long gameId;
|
||||
/**
|
||||
* 评估的版本 ID(待发布版本;无版本时为 0)
|
||||
*/
|
||||
private Long versionId;
|
||||
/**
|
||||
* 聚合裁决:pass/review/block(= max(各原子),与 ComplianceVerdictEnum 编码一致)
|
||||
*/
|
||||
private String verdict;
|
||||
/**
|
||||
* 本次评估落定的内容分级:all/8+/12+/16+(可空)
|
||||
*/
|
||||
private String rating;
|
||||
/**
|
||||
* 各合规原子明细 JSON([{atom,verdict,reason}],便于复核溯源)
|
||||
*/
|
||||
private String details;
|
||||
/**
|
||||
* 全链路追踪 ID(与 publish 链路对账)
|
||||
*/
|
||||
private String traceId;
|
||||
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package cn.wanxiang.game.module.compliance.dal.dataobject.rating;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 内容分级 DO(对应表 game_content_rating)
|
||||
*
|
||||
* 继承 {@link TenantBaseDO}:自动携带审计/租户列。
|
||||
* 唯一键 uk_game_version=(game_id,version_id,deleted,tenant_id):同游戏同版本只存一条最新分级。
|
||||
* 分级写入仅由 Gate evaluate 落定 + admin 复核产生;/app-api/compliance/rating/get 只读取。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@TableName("game_content_rating")
|
||||
@KeySequence("game_content_rating_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ContentRatingDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 内容分级记录 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 游戏 ID(project.game_project.id)
|
||||
*/
|
||||
private Long gameId;
|
||||
/**
|
||||
* 版本 ID(无版本时为 0)
|
||||
*/
|
||||
private Long versionId;
|
||||
/**
|
||||
* 内容分级:all 全年龄/8+/12+/16+
|
||||
*/
|
||||
private String rating;
|
||||
/**
|
||||
* 分级来源:gate 锁风门自动/admin 人工复核
|
||||
*/
|
||||
private String ratedBy;
|
||||
|
||||
}
|
||||
@ -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_version;tenant_id/deleted 由 Yudao 拦截器自动追加)
|
||||
*
|
||||
* @param gameId 游戏 ID
|
||||
* @param versionId 版本 ID(无版本传 0)
|
||||
* @return 分级 DO;不存在返回 null
|
||||
*/
|
||||
default ContentRatingDO selectByGameVersion(Long gameId, Long versionId) {
|
||||
return selectOne(new LambdaQueryWrapperX<ContentRatingDO>()
|
||||
.eq(ContentRatingDO::getGameId, gameId)
|
||||
.eq(ContentRatingDO::getVersionId, versionId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package cn.wanxiang.game.module.compliance.framework.atom;
|
||||
|
||||
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* IP 合规扩展点(D5:ip 不独立建模块,仅留 seam;桩实现,默认 pass)
|
||||
*
|
||||
* D5 决策:IP 侵权检测本波不独立建模块、仅在 compliance 内留扩展点。MVP 桩恒返回 pass。
|
||||
* TODO(Wave4):接 ip 模块(错误码段 108)后只换本实现,GateService 聚合逻辑不变(聚合预留本原子)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Component
|
||||
public class IpComplianceSeam {
|
||||
|
||||
/** 原子标识(落 GateVerdict.detail) */
|
||||
public static final String ATOM = "ip-reserved";
|
||||
|
||||
/**
|
||||
* 评估 IP 合规(D5 桩:默认 pass)
|
||||
*
|
||||
* @param reqDTO 评估入参(title/summary 供未来 IP 比对;本波桩不读取做裁决)
|
||||
* @return 原子裁决(桩恒 pass)
|
||||
*/
|
||||
public ComplianceAtomResult evaluate(ComplianceGateReqDTO reqDTO) {
|
||||
// TODO(Wave4):接 ip 模块做真实 IP 侵权比对(D5 仅留 seam),届时只换本实现
|
||||
return ComplianceAtomResult.pass(ATOM);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package cn.wanxiang.game.module.compliance.framework.atom;
|
||||
|
||||
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 风格合规原子(文本内容/风格扫描;桩实现,默认 pass)
|
||||
*
|
||||
* D2/D5 范围:MVP aigc 侧尚无 T-AGC-19(风格内容合规接口),故本原子为桩,恒返回 pass。
|
||||
* TODO(P1):接 aigc-api 风格内容合规 seam(aigc 补 T-AGC-19 后),将 title/summary/promptHash 透传给 aigc 做真实文本扫描,
|
||||
* 把 aigc 返回的命中规则映射为 review/block。本波不引入 aigc-api 依赖(避免孤儿调用)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Component
|
||||
public class StyleComplianceAtom {
|
||||
|
||||
/** 原子标识(落 GateVerdict.detail) */
|
||||
public static final String ATOM = "textContent";
|
||||
|
||||
/**
|
||||
* 评估风格/文本内容合规(桩:默认 pass)
|
||||
*
|
||||
* @param reqDTO 评估入参(title/summary/promptHash 供未来真实扫描;本波桩不读取做裁决)
|
||||
* @return 原子裁决(桩恒 pass)
|
||||
*/
|
||||
public ComplianceAtomResult evaluate(ComplianceGateReqDTO reqDTO) {
|
||||
// TODO(P1):接 aigc 风格内容合规 seam(T-AGC-19),把 reqDTO.title/summary/promptHash 透传做真实扫描
|
||||
return ComplianceAtomResult.pass(ATOM);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package cn.wanxiang.game.module.compliance.service.ban;
|
||||
|
||||
import cn.wanxiang.game.module.compliance.controller.admin.vo.BanCreateReqVO;
|
||||
import cn.wanxiang.game.module.compliance.controller.admin.vo.BanPageReqVO;
|
||||
import cn.wanxiang.game.module.compliance.dal.dataobject.ban.UserBanDO;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
|
||||
/**
|
||||
* 封禁台账 Service 接口(R3 状态分支)
|
||||
*
|
||||
* 承载:封禁/解封台账写入(主操作恒执行)+ 封禁查重 + 分页。
|
||||
* R3 下架联动:封禁游戏且 game.status==PUBLISHED 时才调 project 权威状态机下架——但 ProjectApi 本波尚无 reviewProject seam,
|
||||
* 故下架联动本波标 TODO/openIssue(见 createBan 注释),只写台账,下架联动留 Phase C 主 agent 收口(不裸调不存在的方法、不自改 project-api)。
|
||||
* R5 降权出流屏蔽:P1 本波不生效(不调 feed、不建 FeedDownweightApi 孤儿 seam)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public interface UserBanService {
|
||||
|
||||
/**
|
||||
* 创建封禁(写 game_user_ban 台账,主操作恒执行;查重防同目标重复生效封禁)
|
||||
*
|
||||
* @param reqVO 封禁入参
|
||||
* @param operatorUserId 操作的 admin 用户 ID(落 creator 审计)
|
||||
* @return 新建封禁台账 ID
|
||||
*/
|
||||
Long createBan(BanCreateReqVO reqVO, Long operatorUserId);
|
||||
|
||||
/**
|
||||
* 解封(status 1→0;记录不存在或已解封报错)
|
||||
*
|
||||
* @param banId 封禁台账 ID
|
||||
*/
|
||||
void cancelBan(Long banId);
|
||||
|
||||
/**
|
||||
* 封禁台账分页(admin RBAC)
|
||||
*
|
||||
* @param reqVO 分页 + 筛选
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<UserBanDO> getBanPage(BanPageReqVO reqVO);
|
||||
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
package cn.wanxiang.game.module.compliance.service.ban;
|
||||
|
||||
import cn.wanxiang.game.module.compliance.controller.admin.vo.BanCreateReqVO;
|
||||
import cn.wanxiang.game.module.compliance.controller.admin.vo.BanPageReqVO;
|
||||
import cn.wanxiang.game.module.compliance.dal.dataobject.ban.UserBanDO;
|
||||
import cn.wanxiang.game.module.compliance.dal.mysql.UserBanMapper;
|
||||
import cn.wanxiang.game.module.compliance.enums.BanStatusEnum;
|
||||
import cn.wanxiang.game.module.compliance.enums.BanTargetTypeEnum;
|
||||
import cn.wanxiang.game.module.project.api.ProjectApi;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import static cn.wanxiang.game.module.compliance.enums.ErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* 封禁台账 Service 实现(R3 状态分支)
|
||||
*
|
||||
* 核心:封禁写 game_user_ban 台账=主操作恒执行(先查重再插入);解封 status 1→0。
|
||||
*
|
||||
* 【R3 下架联动 / 本波边界 = openIssue】
|
||||
* 设计目标:封禁游戏且 game.status==PUBLISHED 时才调 project 权威状态机下架(reviewProject decision=3);非 PUBLISHED 只记台账不调状态机、不抛异常。
|
||||
* 现状:ProjectApi(Phase A)仅有 getCreatorUserId/getCurrentVersionId/createProject,【无 reviewProject seam、无 game 状态查询 seam】。
|
||||
* 决策(按执行 spec §4.5 + 任务硬约束):不裸调不存在的方法、不自改 project-api(那是主 agent Phase A 单点)。
|
||||
* 故本波只写 ban 台账,下架联动收敛在 {@link #maybeUnlistPublishedGame} 一处占位(TODO 明注),留 Phase C 主 agent 收口接 project 状态机。
|
||||
* R5 降权出流屏蔽:P1 本波不生效(不注入 feed、不建 FeedDownweightApi 孤儿 seam)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class UserBanServiceImpl implements UserBanService {
|
||||
|
||||
@Resource
|
||||
private UserBanMapper userBanMapper;
|
||||
|
||||
/**
|
||||
* 依赖 project 的 -api(R3 下架联动收口点预留)。本波仅持有引用、不调用未就绪 seam;
|
||||
* Phase C 主 agent 在 project-api 补 reviewProject/状态查询后,于 {@link #maybeUnlistPublishedGame} 接入。
|
||||
*/
|
||||
@Resource
|
||||
private ProjectApi projectApi;
|
||||
|
||||
@Override
|
||||
public Long createBan(BanCreateReqVO reqVO, Long operatorUserId) {
|
||||
// 1) 目标类型合法校验(仅 1 游戏 / 2 用户)
|
||||
if (!BanTargetTypeEnum.isValid(reqVO.getTargetType())) {
|
||||
throw exception(COMPLIANCE_BAN_TARGET_INVALID);
|
||||
}
|
||||
// 2) 查重:同目标已有生效中(status=1)封禁则拒绝(命中 uk_target_active)
|
||||
if (userBanMapper.selectActiveByTarget(reqVO.getTargetType(), reqVO.getTargetId()) != null) {
|
||||
throw exception(COMPLIANCE_BAN_ALREADY_EXISTS);
|
||||
}
|
||||
// 3) 写封禁台账(主操作恒执行)——ban_type 缺省为 1 封禁
|
||||
UserBanDO ban = new UserBanDO();
|
||||
ban.setTargetType(reqVO.getTargetType());
|
||||
ban.setTargetId(reqVO.getTargetId());
|
||||
ban.setBanType(reqVO.getBanType() == null ? 1 : reqVO.getBanType());
|
||||
ban.setStatus(BanStatusEnum.ACTIVE.getStatus());
|
||||
ban.setReason(StringUtils.hasText(reqVO.getReason()) ? reqVO.getReason() : "");
|
||||
userBanMapper.insert(ban);
|
||||
log.info("[createBan][封禁台账写入 banId={} targetType={} targetId={} operator={}]",
|
||||
ban.getId(), reqVO.getTargetType(), reqVO.getTargetId(), operatorUserId);
|
||||
|
||||
// 4) R3 下架联动(仅游戏目标):本波标 TODO/openIssue,只记台账不调状态机(见类注释)
|
||||
if (BanTargetTypeEnum.isGame(reqVO.getTargetType())) {
|
||||
maybeUnlistPublishedGame(reqVO.getTargetId());
|
||||
}
|
||||
return ban.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelBan(Long banId) {
|
||||
// 解封:记录须存在且当前生效中(status=1);否则报不存在(含已解封)
|
||||
UserBanDO ban = userBanMapper.selectById(banId);
|
||||
if (ban == null || !BanStatusEnum.isActive(ban.getStatus())) {
|
||||
throw exception(COMPLIANCE_BAN_NOT_EXISTS);
|
||||
}
|
||||
UserBanDO update = new UserBanDO();
|
||||
update.setId(banId);
|
||||
update.setStatus(BanStatusEnum.CANCELLED.getStatus());
|
||||
userBanMapper.updateById(update);
|
||||
log.info("[cancelBan][解封 banId={}]", banId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<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 状态查询 seam(Phase A 只加了 getCurrentVersionId/createProject)。
|
||||
* 按硬约束「不裸调不存在的方法、不自改 project-api」,本波不实现真实下架,仅记日志占位。
|
||||
* Phase C 主 agent 收口:在 project-api 补状态查询 + reviewProject seam 后,在此处接:
|
||||
* Integer status = projectApi.getGameStatus(gameId).getCheckedData();
|
||||
* if (ProjectStatusEnum.PUBLISHED.getStatus().equals(status)) projectApi.reviewProject(gameId, REVIEW_UNLIST=3);
|
||||
*
|
||||
* @param gameId 被封禁游戏 ID
|
||||
*/
|
||||
private void maybeUnlistPublishedGame(Long gameId) {
|
||||
// TODO(Phase C 主 agent 收口):接 project reviewProject 状态机下架——仅 PUBLISHED 调,非 PUBLISHED 只记台账不抛异常。
|
||||
// 本波因 ProjectApi 无 reviewProject/状态查询 seam,下架联动不生效(openIssue),ban 台账已写入。
|
||||
log.warn("[maybeUnlistPublishedGame][gameId={} 封禁台账已写入;下架联动 TODO 留 Phase C 收口(project-api 暂无 reviewProject seam)]", gameId);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package cn.wanxiang.game.module.compliance.service.gate;
|
||||
|
||||
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
|
||||
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
||||
|
||||
/**
|
||||
* 合规锁风门 Gate Service 接口
|
||||
*
|
||||
* 承载:聚合各合规原子(风格/IP)→ verdict(max(各原子),block>review>pass)+ 落锁风门台账 + 落内容分级。
|
||||
* 仅做 Gate(D2),绝不重写 project 审核状态机。被 {@code ComplianceGateApiImpl} 委托(project.publish 注入用)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public interface ComplianceGateService {
|
||||
|
||||
/**
|
||||
* 锁风门评估:聚合合规原子产出裁决,并落锁风门台账 + 内容分级
|
||||
*
|
||||
* 聚合语义:verdict = max(各原子),block>review>pass;block 不抛异常,正常返回 verdict(由 project 侧据此置 admitted=false)。
|
||||
* 写副作用:落 game_compliance_gate_result(每次一条审计)+ upsert game_content_rating(分级落定)。
|
||||
*
|
||||
* @param reqDTO 评估入参(gameId/versionId 必填)
|
||||
* @return 聚合裁决(含 verdict/rating/detail)
|
||||
*/
|
||||
GateVerdict evaluate(ComplianceGateReqDTO reqDTO);
|
||||
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
package cn.wanxiang.game.module.compliance.service.gate;
|
||||
|
||||
import cn.wanxiang.game.module.compliance.dal.dataobject.gate.GateResultDO;
|
||||
import cn.wanxiang.game.module.compliance.dal.dataobject.rating.ContentRatingDO;
|
||||
import cn.wanxiang.game.module.compliance.dal.mysql.ContentRatingMapper;
|
||||
import cn.wanxiang.game.module.compliance.dal.mysql.GateResultMapper;
|
||||
import cn.wanxiang.game.module.compliance.dto.ComplianceGateItemDTO;
|
||||
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
|
||||
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
||||
import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum;
|
||||
import cn.wanxiang.game.module.compliance.framework.atom.ComplianceAtomResult;
|
||||
import cn.wanxiang.game.module.compliance.framework.atom.IpComplianceSeam;
|
||||
import cn.wanxiang.game.module.compliance.framework.atom.StyleComplianceAtom;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 合规锁风门 Gate Service 实现
|
||||
*
|
||||
* 聚合各合规原子(风格 {@link StyleComplianceAtom} + IP {@link IpComplianceSeam})→ verdict = max(各原子);
|
||||
* 落锁风门台账 game_compliance_gate_result(每次一条审计)+ upsert 内容分级 game_content_rating。
|
||||
* 仅做 Gate(D2),不碰 project 审核状态机。可单测(聚合/落库均经 mock 验证)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ComplianceGateServiceImpl implements ComplianceGateService {
|
||||
|
||||
@Resource
|
||||
private StyleComplianceAtom styleComplianceAtom;
|
||||
|
||||
@Resource
|
||||
private IpComplianceSeam ipComplianceSeam;
|
||||
|
||||
@Resource
|
||||
private GateResultMapper gateResultMapper;
|
||||
|
||||
@Resource
|
||||
private ContentRatingMapper contentRatingMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public GateVerdict evaluate(ComplianceGateReqDTO reqDTO) {
|
||||
// 1) 逐个执行合规原子(风格内容 + IP;MVP 均为桩默认 pass)
|
||||
List<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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 模块:compliance(game-module-compliance,Wave3 合规域「锁风门 Gate」)| owner:Wave3 compliance 子 agent
|
||||
-- 文件:V9.0.0__create_game_compliance.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V9.0.1 DROP TABLE)
|
||||
-- 内容:compliance 模块三张表 —— 锁风门评估台账 game_compliance_gate_result + 内容分级 game_content_rating + 封禁台账 game_user_ban
|
||||
-- 职责(架构 Doc B + D2 范围铁律):只做 Gate(聚合风格/IP 原子→verdict + 内容分级 + 封禁台账);
|
||||
-- 【明确不建】审核状态机表 / audit_record —— 审核状态机权威归 project(game_review_record),compliance 绝不重写。
|
||||
-- 锁风门 seam(R1):project.submitPublish 同进程调 ComplianceGateApi.evaluate → 聚合裁决落 game_compliance_gate_result + 分级落 game_content_rating
|
||||
-- → 返回 verdict 供 project 据此置 admitted(block→admitted=false 不抛异常)。
|
||||
-- 封禁联动(R3 状态分支):写 game_user_ban 台账(主操作恒执行);下架仅当 project 状态==PUBLISHED 时才调 project reviewProject(3);
|
||||
-- 非 PUBLISHED 只记封禁不调状态机。【本波说明】ProjectApi 暂无 reviewProject seam,下架联动标 TODO 留 Phase C 主 agent 收口,本波仅写台账。
|
||||
-- 降权出流屏蔽:P1 本波不生效(R5,不建 FeedDownweightApi 孤儿 seam)。
|
||||
-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id;
|
||||
-- 状态机用 tinyint,非法流转由 Service 校验,DO 层不承载;中文列注释;关键查询建索引、禁裸 select*。
|
||||
-- 错误码段:compliance = 1-109-***-***
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_compliance_gate_result —— 锁风门评估台账(每次 publish 评估各落一条,审计/溯源用)
|
||||
-- 用途:project.publish 注入 evaluate 时落聚合裁决 + 各原子明细 JSON;admin 复核与合规溯源据此查
|
||||
-- 写口径:非幂等(每次评估各落一条审计记录,不去重);verdict 为聚合结果 max(各原子)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_compliance_gate_result` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '锁风门评估记录 ID',
|
||||
`game_id` BIGINT NOT NULL COMMENT '评估的游戏 ID(project.game_project.id)',
|
||||
`version_id` BIGINT NOT NULL DEFAULT 0 COMMENT '评估的版本 ID(待发布版本;无版本时为 0)',
|
||||
`verdict` VARCHAR(16) NOT NULL DEFAULT 'pass' COMMENT '聚合裁决:pass 通过/review 需人工复核/block 拦截(= max(各原子))',
|
||||
`rating` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '本次评估落定的内容分级:all/8+/12+/16+(可空)',
|
||||
`details` VARCHAR(2048) NOT NULL DEFAULT '' COMMENT '各合规原子明细 JSON([{atom,verdict,reason}],便于复核溯源)',
|
||||
`trace_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '全链路追踪 ID(与 publish 链路对账)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;锁风门评估为 system/发布发起方)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(评估时间)',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_game_version` (`game_id`, `version_id`) COMMENT '按游戏+版本查最近一次锁风门评估(复核/溯源)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '锁风门评估台账(每次 publish 评估落一条,聚合裁决+原子明细;审计/溯源)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_content_rating —— 内容分级表(按游戏+版本唯一;评估时落、admin 复核可改)
|
||||
-- 用途:/app-api/compliance/rating/get 读分级;分级写入仅由 Gate evaluate 落定 + admin 复核产生
|
||||
-- 唯一键:(game_id,version_id) 业务唯一(含 deleted/tenant_id 适配逻辑删+多租户),同游戏同版本只存一条最新分级
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_content_rating` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '内容分级记录 ID',
|
||||
`game_id` BIGINT NOT NULL COMMENT '游戏 ID(project.game_project.id)',
|
||||
`version_id` BIGINT NOT NULL DEFAULT 0 COMMENT '版本 ID(无版本时为 0)',
|
||||
`rating` VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '内容分级:all 全年龄/8+/12+/16+',
|
||||
`rated_by` VARCHAR(32) NOT NULL DEFAULT 'gate' COMMENT '分级来源:gate 锁风门自动/admin 人工复核',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(admin 复核改分级时记录)',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_game_version` (`game_id`, `version_id`, `deleted`, `tenant_id`) COMMENT '游戏+版本分级业务唯一键(含 deleted/tenant_id 适配逻辑删+多租户)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '内容分级表(评估落定+admin 复核;/app-api 只读取)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_user_ban —— 封禁台账(封禁/降权的权威台账;主操作恒执行)
|
||||
-- 用途:admin 封禁游戏/用户落台账(恒执行);R3 下架联动仅当游戏==PUBLISHED 时才调 project 状态机(本波标 TODO);
|
||||
-- R5 降权出流屏蔽 P1 本波不生效(不建 feed seam)
|
||||
-- 唯一键:(target_type,target_id,status) 生效中唯一(status=1 生效,含 deleted/tenant_id),同目标不可重复生效封禁;解封后(status=0)可再次封禁
|
||||
-- 状态机 status:1 生效 → 0 已解封(解封由 admin 触发,Service 校验合法流转)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_user_ban` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '封禁台账记录 ID',
|
||||
`target_type` TINYINT NOT NULL COMMENT '封禁目标类型:1 游戏 / 2 用户',
|
||||
`target_id` BIGINT NOT NULL COMMENT '封禁目标 ID(游戏=game_id / 用户=user_id)',
|
||||
`ban_type` TINYINT NOT NULL DEFAULT 1 COMMENT '处置类型:1 封禁(下架/禁用)/ 2 降权(出流屏蔽,P1 本波不生效)',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '封禁状态机:1 生效 / 0 已解封(合法流转由 Service 校验)',
|
||||
`reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封禁/降权原因(合规处置说明)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;封禁操作的 admin userId)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(封禁时间)',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者(解封操作的 admin userId)',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_target_active` (`target_type`, `target_id`, `status`, `deleted`, `tenant_id`) COMMENT '生效中封禁业务唯一键:同目标 status=1 不可重复(含 deleted/tenant_id 适配逻辑删+多租户)',
|
||||
KEY `idx_type_status` (`target_type`, `status`) COMMENT 'admin 按目标类型+状态分页查封禁台账'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '封禁台账表(封禁/降权权威台账;主操作恒执行,下架联动按 project 状态分支)';
|
||||
@ -0,0 +1,151 @@
|
||||
package cn.wanxiang.game.module.compliance.service.ban;
|
||||
|
||||
import cn.wanxiang.game.module.compliance.controller.admin.vo.BanCreateReqVO;
|
||||
import cn.wanxiang.game.module.compliance.dal.dataobject.ban.UserBanDO;
|
||||
import cn.wanxiang.game.module.compliance.dal.mysql.UserBanMapper;
|
||||
import cn.wanxiang.game.module.compliance.enums.BanStatusEnum;
|
||||
import cn.wanxiang.game.module.compliance.enums.BanTargetTypeEnum;
|
||||
import cn.wanxiang.game.module.project.api.ProjectApi;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import static cn.wanxiang.game.module.compliance.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link UserBanServiceImpl} 单元测试(纯 Mockito,不依赖 DB;仿 AdSlotServiceImplTest 范式)
|
||||
*
|
||||
* 覆盖 R3 封禁状态分支核心:
|
||||
* - 封禁游戏:写台账=主操作恒执行(断言 insert 被调用);下架联动本波 TODO(断言不裸调 project-api,seam 未就绪);
|
||||
* - 封禁用户:写台账(用户目标无下架联动);
|
||||
* - 查重:同目标已有生效封禁 → 报 COMPLIANCE_BAN_ALREADY_EXISTS,不再插入;
|
||||
* - 类型非法 → 报 COMPLIANCE_BAN_TARGET_INVALID。
|
||||
*
|
||||
* 【说明 R3/openIssue】ProjectApi 本波无 reviewProject/状态查询 seam,下架联动留 Phase C;
|
||||
* 故本测试断言「封禁恒写台账 + 不调用 project-api」= 当前 seam 边界下 R3 状态分支的诚实形态(不伪造未就绪的下架调用)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
class UserBanServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private UserBanServiceImpl userBanService;
|
||||
|
||||
@Mock
|
||||
private UserBanMapper userBanMapper;
|
||||
@Mock
|
||||
private ProjectApi projectApi;
|
||||
|
||||
// ============================== createBan ==============================
|
||||
|
||||
@Test
|
||||
void testCreateBan_game_writesLedgerAndNoUnlistCallThisWave() {
|
||||
// 准备:游戏目标,无已生效封禁;insert 回填自增 ID
|
||||
when(userBanMapper.selectActiveByTarget(BanTargetTypeEnum.GAME.getType(), 1024L)).thenReturn(null);
|
||||
doAnswer(inv -> {
|
||||
UserBanDO d = inv.getArgument(0);
|
||||
d.setId(1L);
|
||||
return 1;
|
||||
}).when(userBanMapper).insert(any(UserBanDO.class));
|
||||
|
||||
BanCreateReqVO reqVO = mockBan(BanTargetTypeEnum.GAME.getType(), 1024L, "违规内容");
|
||||
Long id = userBanService.createBan(reqVO, 1L);
|
||||
|
||||
// 断言:台账写入=主操作恒执行(status=生效)
|
||||
assertEquals(1L, id);
|
||||
ArgumentCaptor<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-api(reviewProject seam 未就绪,留 Phase C 收口)
|
||||
verifyNoInteractions(projectApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateBan_user_writesLedgerOnly() {
|
||||
// 准备:用户目标,无已生效封禁
|
||||
when(userBanMapper.selectActiveByTarget(BanTargetTypeEnum.USER.getType(), 99L)).thenReturn(null);
|
||||
doAnswer(inv -> {
|
||||
UserBanDO d = inv.getArgument(0);
|
||||
d.setId(2L);
|
||||
return 1;
|
||||
}).when(userBanMapper).insert(any(UserBanDO.class));
|
||||
|
||||
BanCreateReqVO reqVO = mockBan(BanTargetTypeEnum.USER.getType(), 99L, "封号");
|
||||
Long id = userBanService.createBan(reqVO, 1L);
|
||||
|
||||
// 断言:用户目标只写台账、无任何 project 联动
|
||||
assertEquals(2L, id);
|
||||
verify(userBanMapper).insert(any(UserBanDO.class));
|
||||
verifyNoInteractions(projectApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateBan_alreadyExists() {
|
||||
// 同目标已有生效封禁 → 报已封禁,不再插入
|
||||
when(userBanMapper.selectActiveByTarget(BanTargetTypeEnum.GAME.getType(), 1024L)).thenReturn(new UserBanDO());
|
||||
BanCreateReqVO reqVO = mockBan(BanTargetTypeEnum.GAME.getType(), 1024L, "重复封禁");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> userBanService.createBan(reqVO, 1L));
|
||||
assertEquals(COMPLIANCE_BAN_ALREADY_EXISTS.getCode(), ex.getCode());
|
||||
verify(userBanMapper, never()).insert(any(UserBanDO.class)); // 类型消歧
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateBan_targetTypeInvalid() {
|
||||
// 非法目标类型(如 3)→ 报类型非法
|
||||
BanCreateReqVO reqVO = mockBan(3, 1024L, "非法类型");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> userBanService.createBan(reqVO, 1L));
|
||||
assertEquals(COMPLIANCE_BAN_TARGET_INVALID.getCode(), ex.getCode());
|
||||
verify(userBanMapper, never()).insert(any(UserBanDO.class));
|
||||
}
|
||||
|
||||
// ============================== cancelBan ==============================
|
||||
|
||||
@Test
|
||||
void testCancelBan_success() {
|
||||
// 准备:生效中封禁记录
|
||||
UserBanDO active = new UserBanDO();
|
||||
active.setId(1L);
|
||||
active.setStatus(BanStatusEnum.ACTIVE.getStatus());
|
||||
when(userBanMapper.selectById(1L)).thenReturn(active);
|
||||
|
||||
userBanService.cancelBan(1L);
|
||||
|
||||
ArgumentCaptor<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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
package cn.wanxiang.game.module.compliance.service.gate;
|
||||
|
||||
import cn.wanxiang.game.module.compliance.dal.dataobject.gate.GateResultDO;
|
||||
import cn.wanxiang.game.module.compliance.dal.dataobject.rating.ContentRatingDO;
|
||||
import cn.wanxiang.game.module.compliance.dal.mysql.ContentRatingMapper;
|
||||
import cn.wanxiang.game.module.compliance.dal.mysql.GateResultMapper;
|
||||
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
|
||||
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
||||
import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum;
|
||||
import cn.wanxiang.game.module.compliance.framework.atom.ComplianceAtomResult;
|
||||
import cn.wanxiang.game.module.compliance.framework.atom.IpComplianceSeam;
|
||||
import cn.wanxiang.game.module.compliance.framework.atom.StyleComplianceAtom;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link ComplianceGateServiceImpl} 单元测试(纯 Mockito,不依赖 DB;仿 AdSlotServiceImplTest 范式)
|
||||
*
|
||||
* 覆盖锁风门聚合核心:
|
||||
* - 全原子 pass → 聚合 verdict=pass,落台账 + 落分级;
|
||||
* - 任一原子 block → 聚合 verdict=block(block>review>pass),不抛异常仍正常返回(供 project 置 admitted=false)。
|
||||
* mock BaseMapper.insert/updateById 用 any(DO.class) 消歧(裸 any() 会因重载报错)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
class ComplianceGateServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private ComplianceGateServiceImpl complianceGateService;
|
||||
|
||||
@Mock
|
||||
private StyleComplianceAtom styleComplianceAtom;
|
||||
@Mock
|
||||
private IpComplianceSeam ipComplianceSeam;
|
||||
@Mock
|
||||
private GateResultMapper gateResultMapper;
|
||||
@Mock
|
||||
private ContentRatingMapper contentRatingMapper;
|
||||
|
||||
// ============================== evaluate ==============================
|
||||
|
||||
@Test
|
||||
void testEvaluate_pass() {
|
||||
// 准备:两原子均 pass;分级记录不存在(走 insert 分支)
|
||||
when(styleComplianceAtom.evaluate(any(ComplianceGateReqDTO.class)))
|
||||
.thenReturn(ComplianceAtomResult.pass(StyleComplianceAtom.ATOM));
|
||||
when(ipComplianceSeam.evaluate(any(ComplianceGateReqDTO.class)))
|
||||
.thenReturn(ComplianceAtomResult.pass(IpComplianceSeam.ATOM));
|
||||
when(contentRatingMapper.selectByGameVersion(1024L, 7L)).thenReturn(null);
|
||||
|
||||
ComplianceGateReqDTO req = mockReq(1024L, 7L, "all");
|
||||
GateVerdict verdict = complianceGateService.evaluate(req);
|
||||
|
||||
// 断言:聚合 pass;落台账(verdict=pass);落分级(insert)
|
||||
assertEquals(ComplianceVerdictEnum.PASS.getVerdict(), verdict.getVerdict());
|
||||
assertEquals("all", verdict.getRating());
|
||||
assertEquals(2, verdict.getDetail().size());
|
||||
|
||||
ArgumentCaptor<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() {
|
||||
// 准备:风格原子 block,IP 原子 pass → 聚合应为 block(block>review>pass);分级已存在(走 update 分支)
|
||||
when(styleComplianceAtom.evaluate(any(ComplianceGateReqDTO.class)))
|
||||
.thenReturn(new ComplianceAtomResult(StyleComplianceAtom.ATOM, ComplianceVerdictEnum.BLOCK, "命中违禁词"));
|
||||
when(ipComplianceSeam.evaluate(any(ComplianceGateReqDTO.class)))
|
||||
.thenReturn(ComplianceAtomResult.pass(IpComplianceSeam.ATOM));
|
||||
ContentRatingDO existing = new ContentRatingDO();
|
||||
existing.setId(5L);
|
||||
existing.setGameId(1024L);
|
||||
existing.setVersionId(7L);
|
||||
when(contentRatingMapper.selectByGameVersion(1024L, 7L)).thenReturn(existing);
|
||||
|
||||
ComplianceGateReqDTO req = mockReq(1024L, 7L, "12+");
|
||||
// block 不抛异常,正常返回 verdict
|
||||
GateVerdict verdict = assertDoesNotThrow(() -> complianceGateService.evaluate(req));
|
||||
|
||||
// 断言:聚合 block;台账记 block;分级走 update(已存在)
|
||||
assertEquals(ComplianceVerdictEnum.BLOCK.getVerdict(), verdict.getVerdict());
|
||||
assertEquals("12+", verdict.getRating());
|
||||
ArgumentCaptor<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;
|
||||
}
|
||||
|
||||
}
|
||||
27
game-cloud/game-module-compliance/pom.xml
Normal file
27
game-cloud/game-module-compliance/pom.xml
Normal 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 聚合父 POM(groupId 复用 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>
|
||||
@ -31,6 +31,13 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 跨模块契约:project -api(GAP-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>
|
||||
|
||||
@ -33,4 +33,7 @@ public class ShareMetaRespVO {
|
||||
@Schema(description = "回显的渠道标识(渠道归因 T-FED-10)", example = "wechat")
|
||||
private String channel;
|
||||
|
||||
@Schema(description = "当前生效版本 ID(GAP-3,与 FeedItem.versionId 同源 project.currentVersionId;仅已发布游戏回填,否则为空)", example = "2048")
|
||||
private Long versionId;
|
||||
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO;
|
||||
import cn.wanxiang.game.module.feed.dal.mysql.interact.FeedInteractLogMapper;
|
||||
import cn.wanxiang.game.module.feed.dal.mysql.rank.FeedRankMapper;
|
||||
import cn.wanxiang.game.module.feed.enums.FeedActionEnum;
|
||||
import cn.wanxiang.game.module.project.api.ProjectApi;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -51,6 +52,15 @@ public class FeedServiceImpl implements FeedService {
|
||||
@Resource
|
||||
private FeedInteractLogMapper feedInteractLogMapper;
|
||||
|
||||
/**
|
||||
* project 模块 RPC 契约(仅依赖 -api)
|
||||
*
|
||||
* GAP-3:分享元数据回填当前生效版本 ID(getCurrentVersionId),供分享落地页直达即玩加载。
|
||||
* MVP 单体:由 project 模块 ProjectApiImpl(@RestController @Primary) 就地解析。
|
||||
*/
|
||||
@Resource
|
||||
private ProjectApi projectApi;
|
||||
|
||||
// ============================== 游戏流 ==============================
|
||||
|
||||
@Override
|
||||
@ -154,7 +164,8 @@ public class FeedServiceImpl implements FeedService {
|
||||
@Override
|
||||
public ShareMetaRespVO getShareMeta(Long gameId, String channel) {
|
||||
// 发布门禁:仅 status=4 已发布可分享
|
||||
if (!isGamePublished(gameId)) {
|
||||
boolean published = isGamePublished(gameId);
|
||||
if (!published) {
|
||||
throw exception(FEED_SHARE_GAME_NOT_PUBLISHED);
|
||||
}
|
||||
ShareMetaRespVO meta = new ShareMetaRespVO();
|
||||
@ -166,9 +177,31 @@ public class FeedServiceImpl implements FeedService {
|
||||
meta.setOgTitle("");
|
||||
meta.setOgImage("");
|
||||
meta.setOgDescription("");
|
||||
// GAP-3 当前生效版本回填:仅已发布游戏回填 versionId(与 FeedItem.versionId 同源 project.currentVersionId),
|
||||
// 供分享落地页直达即玩加载。getCurrentVersionId 对「不存在/无生效版本」返回 null(code=0 + data=null,
|
||||
// getCheckedData() 不抛异常),此处随之留空,不阻断分享。
|
||||
meta.setVersionId(resolveCurrentVersionId(gameId, published));
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前生效版本 ID(GAP-3 回填,仅已发布游戏回填)
|
||||
*
|
||||
* 仅当游戏已发布时调 project -api 取当前生效版本;未发布或 project 侧无生效版本(返回 null)时留空,由前端做缺省兜底。
|
||||
* 提取为独立方法便于单测桩控正反两路(已发布有版本 / 已发布无版本)。
|
||||
*
|
||||
* @param gameId 游戏 ID
|
||||
* @param published 是否已发布(getShareMeta 已门禁,此处恒 true;保留入参以表达「仅已发布回填」语义)
|
||||
* @return 当前生效版本 ID;未发布或无生效版本返回 null
|
||||
*/
|
||||
private Long resolveCurrentVersionId(Long gameId, boolean published) {
|
||||
if (!published) {
|
||||
return null;
|
||||
}
|
||||
// getCheckedData():code=0 时返回 data(可能为 null,project 侧约定无生效版本即 null),非 0 抛异常
|
||||
return projectApi.getCurrentVersionId(gameId).getCheckedData();
|
||||
}
|
||||
|
||||
// ============================== 精选池运营 ==============================
|
||||
|
||||
@Override
|
||||
|
||||
@ -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 ==============================
|
||||
|
||||
@ -26,6 +26,10 @@ public class RuntimePackageRespVO {
|
||||
@Schema(description = "GamePackage(manifest)OSS/CDN URL(按 /games/{gameId}/versions/{versionId}/ 版本化)")
|
||||
private String packageUrl;
|
||||
|
||||
@Schema(description = "manifest.json 取包清单 URL(GAP-2 分支A:由 packageUrl 同前缀派生 .../manifest.json,宿主先 fetch 校验 sha256 再注入)",
|
||||
example = "https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json")
|
||||
private String manifestUrl;
|
||||
|
||||
@Schema(description = "入口文件相对路径(#4 manifest.entry)", example = "index.html")
|
||||
private String entry;
|
||||
|
||||
|
||||
@ -41,6 +41,9 @@ public class RuntimeConvert {
|
||||
}
|
||||
// 平铺字段(gameId/versionId/templateId/packageUrl/entry/runtimeVersion/preloadPolicy/bundleSize/checksum 同名直拷)
|
||||
RuntimePackageRespVO vo = BeanUtils.toBean(pkg, RuntimePackageRespVO.class);
|
||||
// GAP-2 分支A(纯派生,零 DDL):manifestUrl 由 packageUrl 同前缀派生 .../manifest.json,
|
||||
// 宿主据此先 fetch manifest 并按 checksum 做 sha256 完整性校验,通过后再注入运行容器(T-RT-15)。
|
||||
vo.setManifestUrl(deriveManifestUrl(pkg.getPackageUrl()));
|
||||
// 沙箱策略单独组装:sandboxAttr 直拷,allowOrigins 逗号串拆 List
|
||||
SandboxPolicyVO sandbox = new SandboxPolicyVO();
|
||||
sandbox.setSandboxAttr(pkg.getSandboxAttr());
|
||||
@ -49,6 +52,30 @@ public class RuntimeConvert {
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 packageUrl 同前缀派生 manifest.json 取包清单 URL(GAP-2 分支A,纯派生不增列)
|
||||
*
|
||||
* 规则:取 packageUrl 末段 '/' 之前的同级目录前缀,拼接 manifest.json。
|
||||
* - 以 '/' 结尾(目录形态,如 .../versions/2048/):直接追加 manifest.json;
|
||||
* - 含文件名(如 .../2048/package.zip):替换末段文件名为 manifest.json,保持同前缀同级;
|
||||
* - 空/空白:返回 null(无包则无清单,宿主走兜底,不构造非法 URL)。
|
||||
*
|
||||
* @param packageUrl 运行包 OSS/CDN URL(按 /games/{gameId}/versions/{versionId}/ 版本化)
|
||||
* @return manifest.json 清单 URL;packageUrl 空时返回 null
|
||||
*/
|
||||
public static String deriveManifestUrl(String packageUrl) {
|
||||
if (!StringUtils.hasText(packageUrl)) {
|
||||
return null;
|
||||
}
|
||||
int lastSlash = packageUrl.lastIndexOf('/');
|
||||
// 无 '/'(异常退化形态):直接当作前缀目录,拼接 manifest.json,避免越权改写其它路径
|
||||
if (lastSlash < 0) {
|
||||
return packageUrl + "/manifest.json";
|
||||
}
|
||||
// 保留含末位 '/' 的同级前缀,替换末段文件名(或空段)为 manifest.json
|
||||
return packageUrl.substring(0, lastSlash + 1) + "manifest.json";
|
||||
}
|
||||
|
||||
// ============================== 编译任务 ==============================
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
package cn.wanxiang.game.module.runtime.convert.runtime;
|
||||
|
||||
import cn.wanxiang.game.module.runtime.controller.app.runtime.vo.RuntimePackageRespVO;
|
||||
import cn.wanxiang.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link RuntimeConvert} 单元测试(纯静态方法,不依赖 Spring/DB)
|
||||
*
|
||||
* 覆盖 GAP-2 分支A:manifestUrl 由 packageUrl 同前缀派生 .../manifest.json(含目录形态/文件名形态/空值/退化形态各路)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
class RuntimeConvertTest {
|
||||
|
||||
// ============================== deriveManifestUrl 同前缀派生 ==============================
|
||||
|
||||
@Test
|
||||
void testDeriveManifestUrl_dirForm() {
|
||||
// packageUrl 以 '/' 结尾(版本化目录形态)→ 直接追加 manifest.json
|
||||
assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json",
|
||||
RuntimeConvert.deriveManifestUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeriveManifestUrl_fileForm() {
|
||||
// packageUrl 含末段文件名 → 替换为同级 manifest.json,保持同前缀
|
||||
assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json",
|
||||
RuntimeConvert.deriveManifestUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeriveManifestUrl_noSlashDegraded() {
|
||||
// 无 '/' 退化形态 → 当作前缀目录拼接,不越权改写其它路径
|
||||
assertEquals("pkg/manifest.json", RuntimeConvert.deriveManifestUrl("pkg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeriveManifestUrl_blankReturnsNull() {
|
||||
// 空/空白 packageUrl → 返回 null(无包则无清单,不构造非法 URL)
|
||||
assertNull(RuntimeConvert.deriveManifestUrl(null));
|
||||
assertNull(RuntimeConvert.deriveManifestUrl(""));
|
||||
assertNull(RuntimeConvert.deriveManifestUrl(" "));
|
||||
}
|
||||
|
||||
// ============================== toPackageRespVO 整体回填 ==============================
|
||||
|
||||
@Test
|
||||
void testToPackageRespVO_manifestUrlDerivedAndSandboxAssembled() {
|
||||
RuntimePackageDO pkg = new RuntimePackageDO();
|
||||
pkg.setGameId(1024L);
|
||||
pkg.setVersionId(2048L);
|
||||
pkg.setPackageUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip");
|
||||
pkg.setSandboxAttr("allow-scripts");
|
||||
pkg.setAllowOrigins("https://a.com,https://b.com");
|
||||
|
||||
RuntimePackageRespVO vo = RuntimeConvert.toPackageRespVO(pkg);
|
||||
|
||||
assertNotNull(vo);
|
||||
// 平铺字段直拷 + manifestUrl 同前缀派生
|
||||
assertEquals(1024L, vo.getGameId());
|
||||
assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip", vo.getPackageUrl());
|
||||
assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json", vo.getManifestUrl());
|
||||
// 沙箱策略 allowOrigins 字符串拆 List
|
||||
assertNotNull(vo.getSandbox());
|
||||
assertEquals("allow-scripts", vo.getSandbox().getSandboxAttr());
|
||||
assertEquals(2, vo.getSandbox().getAllowOrigins().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testToPackageRespVO_nullReturnsNull() {
|
||||
assertNull(RuntimeConvert.toPackageRespVO(null));
|
||||
}
|
||||
|
||||
}
|
||||
49
game-cloud/game-module-studio/game-module-studio-api/pom.xml
Normal file
49
game-cloud/game-module-studio/game-module-studio-api/pom.xml
Normal 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>
|
||||
@ -0,0 +1,32 @@
|
||||
package cn.wanxiang.game.module.studio.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.RpcConstants;
|
||||
|
||||
/**
|
||||
* studio 模块 API 相关枚举(Feign 服务名 + RPC 前缀)
|
||||
*
|
||||
* 仿 yudao system / project / ad 的 {@code ApiConstants} 风格。MVP 单体下 studio 仅作 /app-api 创作编排入口,
|
||||
* 暂不对外暴露跨模块 Feign 契约(studio 是调用方:调 project-api / aigc-api),本类保留以备拆微服务与统一前缀。
|
||||
* - NAME:Feign 目标服务名。MVP 为单体(spring.application.name=yudao-server,所有模块编入一个 JAR)。
|
||||
* - PREFIX:RPC 接口前缀 /rpc-api/studio,与业务端 /app-api 隔离(预留)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public class ApiConstants {
|
||||
|
||||
/**
|
||||
* 服务名(拆微服务后需与 studio 服务的 spring.application.name 一致;MVP 单体下仅作占位)
|
||||
*/
|
||||
public static final String NAME = "studio-server";
|
||||
|
||||
/**
|
||||
* RPC API 前缀(/rpc-api/studio,预留)
|
||||
*/
|
||||
public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/studio";
|
||||
|
||||
/**
|
||||
* 接口版本
|
||||
*/
|
||||
public static final String VERSION = "1.0.0";
|
||||
|
||||
}
|
||||
@ -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 调用异常)");
|
||||
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package cn.wanxiang.game.module.studio.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 创作会话状态机枚举(对齐契约 studio.yaml + db V8.0.0 game_studio_session.status)
|
||||
*
|
||||
* MVP 最小版:草稿 → 生成中 → 完成/失败(线性单步任务链 DRAFT→GENERATING→DONE/FAILED,对齐 P-CRT-01/09)。
|
||||
* 状态来源:DRAFT 由 createDraft 写;GENERATING 由 generate 写;DONE/FAILED 由 getTask 轮询透传 aigc 终态映射。
|
||||
* 注意:状态机合法性由 StudioService 校验,DO 层只承载数据。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum StudioSessionStatusEnum {
|
||||
|
||||
DRAFT(0, "草稿"),
|
||||
GENERATING(1, "生成中"),
|
||||
DONE(2, "完成"),
|
||||
FAILED(3, "失败");
|
||||
|
||||
/** 状态值(落库 tinyint) */
|
||||
private final Integer status;
|
||||
/** 状态名(展示用) */
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 根据状态值查枚举
|
||||
*
|
||||
* @param status 状态值
|
||||
* @return 枚举;非法值返回 null
|
||||
*/
|
||||
public static StudioSessionStatusEnum of(Integer status) {
|
||||
return Arrays.stream(values())
|
||||
.filter(e -> Objects.equals(e.status, status))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 的 -api:createDraft 调 createProject 建游戏拿 gameId(跨模块只依赖对方 -api,禁依赖 -server) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>game-module-project-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 编排依赖 aigc 的 -api:generate/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>
|
||||
|
||||
<!-- DB:MyBatis Plus(BaseMapperX / 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>
|
||||
@ -0,0 +1,84 @@
|
||||
package cn.wanxiang.game.module.studio.controller.app.studio;
|
||||
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioDraftCreateReqVO;
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioGenerateReqVO;
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioSessionRespVO;
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioTaskChainRespVO;
|
||||
import cn.wanxiang.game.module.studio.convert.studio.StudioConvert;
|
||||
import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO;
|
||||
import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO;
|
||||
import cn.wanxiang.game.module.studio.service.studio.StudioService;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 产品端(game-studio)- 创作工作台控制器(创作主链路编排入口)
|
||||
*
|
||||
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加(见 WebProperties)。
|
||||
* 鉴权:所有端点需用户 Token;创作者只能操作自己的会话/任务链,归属校验在 Service 强制(不依赖前端)。
|
||||
* 编排链路(MVP 轮询非 MQ,D4):模板浏览 → 建草稿(委托 project) → 一句话生成(委托 aigc) → 进度轮询 → 重生成。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Tag(name = "用户 App - 创作工作台")
|
||||
@RestController
|
||||
@RequestMapping("/studio")
|
||||
@Validated
|
||||
public class AppStudioController {
|
||||
|
||||
@Resource
|
||||
private StudioService studioService;
|
||||
|
||||
@GetMapping("/template/list")
|
||||
@Operation(summary = "玩法模板列表", description = "模板浏览/应用(P-TPL-01/03),透传 aigc 模板注册表")
|
||||
public CommonResult<List<AigcTemplateDTO>> getTemplateList() {
|
||||
return success(studioService.getTemplateList());
|
||||
}
|
||||
|
||||
@PostMapping("/draft")
|
||||
@Operation(summary = "建草稿", description = "进工作坊(P-CRT-10)+ 草稿移交 project(P-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 = "创作任务链 ID(taskChainId)", 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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package cn.wanxiang.game.module.studio.controller.app.studio.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 建草稿 Request VO(对齐契约 studio.yaml StudioDraftCreateReqVO;P-CRT-10 进工作坊 + P-TPL-03 草稿移交 project)
|
||||
*
|
||||
* 编排:服务端调 ProjectApi.createProject(creatorUserId, title, templateId) 建游戏拿 gameId 回写会话,再 upsert 会话(status=0)。
|
||||
* attachments:JSON 字符串持久化(P-CRT-04);本波仅持久化不透传 aigc(R7)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "产品端 - 建草稿 Request VO")
|
||||
@Data
|
||||
public class StudioDraftCreateReqVO {
|
||||
|
||||
@Schema(description = "作品标题(透传 project createProject)", requiredMode = Schema.RequiredMode.REQUIRED, example = "我的放置小镇")
|
||||
@NotBlank(message = "作品标题不能为空")
|
||||
@Size(max = 128, message = "标题不能超过 128 个字符")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "玩法模板 ID(透传 project/aigc)", requiredMode = Schema.RequiredMode.REQUIRED, example = "idle")
|
||||
@NotBlank(message = "玩法模板不能为空")
|
||||
private String templateId;
|
||||
|
||||
@Schema(description = "一句话自然语言 Prompt(建草稿时可空,generate 时必填)", example = "做一个躲避陨石的小游戏")
|
||||
@Size(max = 1000, message = "Prompt 不能超过 1000 个字符")
|
||||
private String prompt;
|
||||
|
||||
@Schema(description = "附件上下文 JSON(P-CRT-04);本波仅持久化,P1 透传 aigc")
|
||||
private String attachments;
|
||||
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package cn.wanxiang.game.module.studio.controller.app.studio.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 一句话生成 Request VO(对齐契约 studio.yaml StudioGenerateReqVO;P-CRT-01 一句话生成)
|
||||
*
|
||||
* 编排:校验 session 归属 → 落任务链(status=1) → 委托 AigcApi.submitGenerate(prompt/templateId/gameId/userId) → 回填 aigc_task_id。
|
||||
* 同步直写无 MQ(D4);attachments 仅持久化(R7),不入 aigc reqDTO(aigc reqDTO 无 attachments 字段)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "产品端 - 一句话生成 Request VO")
|
||||
@Data
|
||||
public class StudioGenerateReqVO {
|
||||
|
||||
@Schema(description = "创作会话 ID(建草稿返回的 sessionId)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
@NotNull(message = "创作会话 ID 不能为空")
|
||||
private Long sessionId;
|
||||
|
||||
@Schema(description = "一句话自然语言 Prompt(透传 aigc submitGenerate)", requiredMode = Schema.RequiredMode.REQUIRED, example = "做一个躲避陨石的小游戏")
|
||||
@NotBlank(message = "生成 Prompt 不能为空")
|
||||
@Size(max = 1000, message = "Prompt 不能超过 1000 个字符")
|
||||
private String prompt;
|
||||
|
||||
@Schema(description = "附件上下文 JSON(P-CRT-04);【本波仅持久化,P1 透传 aigc】——studio 侧落库持久化,本波不透传 aigc")
|
||||
private String attachments;
|
||||
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package cn.wanxiang.game.module.studio.controller.app.studio.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 创作会话响应 VO(对齐契约 studio.yaml StudioSessionRespVO;建草稿/会话详情返回)
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "产品端 - 创作会话 Response VO")
|
||||
@Data
|
||||
public class StudioSessionRespVO {
|
||||
|
||||
@Schema(description = "创作会话 ID(sessionId)", example = "1024")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "移交后的游戏 ID(project.game_project.id)", example = "2048")
|
||||
private Long gameId;
|
||||
|
||||
@Schema(description = "玩法模板 ID", example = "idle")
|
||||
private String templateId;
|
||||
|
||||
@Schema(description = "作品标题", example = "我的放置小镇")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "一句话 Prompt", example = "做一个躲避陨石的小游戏")
|
||||
private String prompt;
|
||||
|
||||
@Schema(description = "会话状态:0草稿 1生成中 2完成 3失败", example = "0")
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package cn.wanxiang.game.module.studio.controller.app.studio.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 创作任务链响应 VO(对齐契约 studio.yaml StudioTaskChainRespVO;generate/轮询/重生成返回)
|
||||
*
|
||||
* 进度轮询(P-CRT-12):前端据本 VO 的 status/progress 轮询直至终态;status=2 完成后 versionId 非空。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "产品端 - 创作任务链 Response VO")
|
||||
@Data
|
||||
public class StudioTaskChainRespVO {
|
||||
|
||||
@Schema(description = "创作任务链 ID(taskChainId,轮询用)", example = "1024")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "所属创作会话 ID", example = "512")
|
||||
private Long sessionId;
|
||||
|
||||
@Schema(description = "委托的 aigc 生成任务 ID", example = "8888")
|
||||
private Long aigcTaskId;
|
||||
|
||||
@Schema(description = "关联游戏 ID(project.game_project.id)", example = "2048")
|
||||
private Long gameId;
|
||||
|
||||
@Schema(description = "产物版本 ID(project.game_version.id);完成后非空", example = "4096")
|
||||
private Long versionId;
|
||||
|
||||
@Schema(description = "任务链状态:0初始 1生成中 2完成 3失败", example = "1")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "进度百分比 0-100(透传 aigc)", example = "60")
|
||||
private Integer progress;
|
||||
|
||||
@Schema(description = "若为重生成,指向原任务链 ID(P-CRT-09 血缘)", example = "1000")
|
||||
private Long retryOf;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package cn.wanxiang.game.module.studio.dal.dataobject.studio;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 创作资产槽位 DO(对应表 game_studio_asset,单步出整包槽位 seam)
|
||||
*
|
||||
* 继承 {@link TenantBaseDO}:自动携带审计/租户列。
|
||||
* 狠切(R7/草稿 scope):MVP 仅落槽位占位 seam,不实现六路并发资产生成;资产由 aigc 单步整包产出,studio 仅登记槽位。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@TableName("game_studio_asset")
|
||||
@KeySequence("game_studio_asset_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class StudioAssetDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 资产槽位 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 所属创作会话 ID(= game_studio_session.id)
|
||||
*/
|
||||
private Long sessionId;
|
||||
/**
|
||||
* 资产槽位类型(如 sprite/background/audio/icon;MVP 槽位占位)
|
||||
*/
|
||||
private String assetType;
|
||||
/**
|
||||
* 资产 URL(OSS/MinIO;单步整包产出后登记)
|
||||
*/
|
||||
private String assetUrl;
|
||||
/**
|
||||
* 槽位状态:0未生成 1已生成(Service 校验,MVP 单步直填)
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package cn.wanxiang.game.module.studio.dal.dataobject.studio;
|
||||
|
||||
import cn.wanxiang.game.module.studio.enums.StudioSessionStatusEnum;
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 创作会话 DO(对应表 game_studio_session,有状态创作工作台主记录)
|
||||
*
|
||||
* 继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列。
|
||||
* 边界:项目/版本权威归 project(本类仅持 game_id 引用,移交后回写);状态机合法性由 StudioService 校验,DO 层只承载数据。
|
||||
* attachments:本波仅持久化(R7),不透传 aigc。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@TableName("game_studio_session")
|
||||
@KeySequence("game_studio_session_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class StudioSessionDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 创作会话 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 创作者用户 ID(业务字段;DataPermission:创作者只见自己会话。区别于审计列 creator)
|
||||
*/
|
||||
private Long creatorUserId;
|
||||
/**
|
||||
* 移交后的游戏 ID(project.game_project.id);createDraft 调 createProject 后回写
|
||||
*/
|
||||
private Long gameId;
|
||||
/**
|
||||
* 玩法模板 ID(透传 aigc,如 dodge/runner/clicker/puzzle/shooter)
|
||||
*/
|
||||
private String templateId;
|
||||
/**
|
||||
* 作品标题(建草稿入参,透传 project createProject)
|
||||
*/
|
||||
private String title;
|
||||
/**
|
||||
* 一句话自然语言 Prompt(透传 aigc submitGenerate)
|
||||
*/
|
||||
private String prompt;
|
||||
/**
|
||||
* 附件上下文(P-CRT-04,JSON 字符串);本波仅持久化,P1 透传 aigc
|
||||
*/
|
||||
private String attachments;
|
||||
/**
|
||||
* 会话状态机(枚举 {@link StudioSessionStatusEnum}):0草稿 1生成中 2完成 3失败
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package cn.wanxiang.game.module.studio.dal.dataobject.studio;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 创作任务链 DO(对应表 game_studio_taskchain,线性单步编排)
|
||||
*
|
||||
* 继承 {@link TenantBaseDO}:自动携带审计/租户列。
|
||||
* 引用边界:aigc_task_id 引用 aigc.game_aigc_task.id(生成权威归 aigc);version_id 引用 project.game_version.id
|
||||
* (版本权威归 project,aigc succeeded 后回填);本类只持引用 + 自身链态,不重建生成状态机。
|
||||
* 状态机:0初始 1生成中 2完成 3失败(透传 aigc 终态映射),合法性由 StudioService 校验。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@TableName("game_studio_taskchain")
|
||||
@KeySequence("game_studio_taskchain_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class StudioTaskChainDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 创作任务链 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 所属创作会话 ID(= game_studio_session.id)
|
||||
*/
|
||||
private Long sessionId;
|
||||
/**
|
||||
* 创作者用户 ID(DataPermission 边界,归属校验用)
|
||||
*/
|
||||
private Long creatorUserId;
|
||||
/**
|
||||
* 委托的 aigc 生成任务 ID(= aigc.game_aigc_task.id);generate 后回填,轮询据此查 aigc
|
||||
*/
|
||||
private Long aigcTaskId;
|
||||
/**
|
||||
* 关联游戏 ID(project.game_project.id,透传 aigc 用)
|
||||
*/
|
||||
private Long gameId;
|
||||
/**
|
||||
* 产物版本 ID(project.game_version.id);aigc succeeded 后由 getTask 回填
|
||||
*/
|
||||
private Long versionId;
|
||||
/**
|
||||
* 任务链状态机:0初始 1生成中 2完成 3失败(透传 aigc 终态映射,Service 校验流转)
|
||||
*/
|
||||
private Integer status;
|
||||
/**
|
||||
* 进度百分比 0-100(透传 aigc progress,供前端进度条 P-CRT-12)
|
||||
*/
|
||||
private Integer progress;
|
||||
/**
|
||||
* 若为重生成,指向原任务链 ID(P-CRT-09 重生成血缘)
|
||||
*/
|
||||
private Long retryOf;
|
||||
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 当前登录创作者 ID(DataPermission 边界)
|
||||
* @return 分页结果
|
||||
*/
|
||||
default PageResult<StudioSessionDO> selectMyPage(PageParam pageParam, Long creatorUserId) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<StudioSessionDO>()
|
||||
.eq(StudioSessionDO::getCreatorUserId, creatorUserId) // 归属边界:只查自己
|
||||
.orderByDesc(StudioSessionDO::getId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package cn.wanxiang.game.module.studio.service.studio;
|
||||
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioDraftCreateReqVO;
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioGenerateReqVO;
|
||||
import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO;
|
||||
import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 创作工作台 Service 接口(创作主链路编排)
|
||||
*
|
||||
* 编排链路(MVP 轮询非 MQ,D4):模板浏览 → 建草稿(委托 project createProject) → 一句话生成(委托 aigc submitGenerate)
|
||||
* → 进度轮询(透传 aigc getTask) → 重生成(委托 aigc retryTask)。
|
||||
* 边界(Doc B §71-76):不跑 LLM(委托 aigc)、不持久化项目版本(交 project)、不做锁风裁决(调 compliance)。
|
||||
* 归属边界:创作者只能操作自己的会话/任务链,归属校验在本 Service 强制(不依赖前端)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public interface StudioService {
|
||||
|
||||
/**
|
||||
* 玩法模板列表(P-TPL-01/03 模板浏览,透传 aigc 模板注册表)
|
||||
*
|
||||
* @return 启用的模板列表
|
||||
*/
|
||||
List<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 reqDTO(R7)。同步直写无 MQ(D4)。
|
||||
*
|
||||
* @param reqVO 生成入参(sessionId/prompt/attachments)
|
||||
* @param userId 当前登录创作者 ID
|
||||
* @return 新建任务链 DO(含 id + aigc_task_id)
|
||||
*/
|
||||
StudioTaskChainDO generate(StudioGenerateReqVO reqVO, Long userId);
|
||||
|
||||
/**
|
||||
* 进度轮询(P-CRT-12,透传 aigc getTask)
|
||||
*
|
||||
* 编排:校验任务链归属 → 委托 AigcApi.getTask 取 aigc 进度/状态 → 映射任务链态
|
||||
* (aigc succeeded → 链完成回填 version_id + 会话完成;failed/timed_out → 链失败 + 会话失败)→ 回写后返回。
|
||||
*
|
||||
* @param taskChainId 任务链 ID
|
||||
* @param userId 当前登录创作者 ID
|
||||
* @return 回写后的任务链 DO
|
||||
*/
|
||||
StudioTaskChainDO getTaskChain(Long taskChainId, Long userId);
|
||||
|
||||
/**
|
||||
* 重生成迭代(P-CRT-09)
|
||||
*
|
||||
* 编排:校验原任务链归属 → 委托 AigcApi.retryTask 重提生成 → 新建一条任务链(retry_of=原 id)回填新 aigc_task_id。
|
||||
*
|
||||
* @param taskChainId 原任务链 ID
|
||||
* @param userId 当前登录创作者 ID
|
||||
* @return 新建的重试任务链 DO
|
||||
*/
|
||||
StudioTaskChainDO regenerate(Long taskChainId, Long userId);
|
||||
|
||||
}
|
||||
@ -0,0 +1,303 @@
|
||||
package cn.wanxiang.game.module.studio.service.studio;
|
||||
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioDraftCreateReqVO;
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioGenerateReqVO;
|
||||
import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO;
|
||||
import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO;
|
||||
import cn.wanxiang.game.module.studio.dal.mysql.studio.StudioSessionMapper;
|
||||
import cn.wanxiang.game.module.studio.dal.mysql.studio.StudioTaskChainMapper;
|
||||
import cn.wanxiang.game.module.studio.enums.StudioSessionStatusEnum;
|
||||
import cn.wanxiang.game.module.aigc.api.AigcApi;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcGenerateReqDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcGenerateRespDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcTemplateDTO;
|
||||
import cn.wanxiang.game.module.aigc.enums.AigcTaskStatusEnum;
|
||||
import cn.wanxiang.game.module.project.api.ProjectApi;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static cn.wanxiang.game.module.studio.enums.ErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* 创作工作台 Service 实现(创作主链路编排核心)
|
||||
*
|
||||
* 编排纪律(Doc B §71-76 + R2/R7/D4):
|
||||
* - createDraft 调 project ProjectApi.createProject 建游戏(R2 createProject 是硬前置,不接受 game_id 恒空兜底);
|
||||
* - generate/getTask/regenerate 委托 aigc AigcApi(生成权威归 aigc,studio 只持引用 + 链态);
|
||||
* - 进度轮询非 MQ(D4,同步透传 aigc getTask);
|
||||
* - attachments 仅持久化(R7),不入 aigc reqDTO。
|
||||
* 跨模块只依赖对方 -api(ProjectApi / AigcApi 走 Feign,MVP 单体由对方 @Primary 实现就地解析),禁依赖 -server。
|
||||
* 失败路径与异常均带中文日志(traceId 贯穿),归属校验失败抛 1-112-000-001。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Service
|
||||
public class StudioServiceImpl implements StudioService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(StudioServiceImpl.class);
|
||||
|
||||
@Resource
|
||||
private StudioSessionMapper studioSessionMapper;
|
||||
|
||||
@Resource
|
||||
private StudioTaskChainMapper studioTaskChainMapper;
|
||||
|
||||
/**
|
||||
* 编排依赖 project 的 -api(跨模块只依赖对方 -api 走 Feign,禁依赖 -server)。
|
||||
* MVP 单体:由 project 的 ProjectApiImpl(@RestController @Primary) 就地解析。
|
||||
*/
|
||||
@Resource
|
||||
private ProjectApi projectApi;
|
||||
|
||||
/**
|
||||
* 编排依赖 aigc 的 -api(Wave3 新增 seam)。MVP 单体:由 aigc 的 AigcApiImpl(@RestController @Primary) 就地解析。
|
||||
*/
|
||||
@Resource
|
||||
private AigcApi aigcApi;
|
||||
|
||||
@Override
|
||||
public List<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 草稿),回写移交后的 gameId;attachments 仅持久化(R7)
|
||||
StudioSessionDO session = new StudioSessionDO();
|
||||
session.setCreatorUserId(userId);
|
||||
session.setGameId(gameId);
|
||||
session.setTemplateId(reqVO.getTemplateId());
|
||||
session.setTitle(reqVO.getTitle());
|
||||
session.setPrompt(reqVO.getPrompt());
|
||||
session.setAttachments(reqVO.getAttachments());
|
||||
session.setStatus(StudioSessionStatusEnum.DRAFT.getStatus());
|
||||
studioSessionMapper.insert(session);
|
||||
log.info("[createDraft] 草稿已建 sessionId={} gameId={} userId={} templateId={}",
|
||||
session.getId(), gameId, userId, reqVO.getTemplateId());
|
||||
return session;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudioTaskChainDO generate(StudioGenerateReqVO reqVO, Long userId) {
|
||||
// 1) 校验 session 存在 + 归属(创作者只能操作自己的会话)
|
||||
StudioSessionDO session = validateSessionOwner(reqVO.getSessionId(), userId);
|
||||
|
||||
// 2) attachments 仅持久化(R7):回写会话 prompt/attachments(不入 aigc reqDTO)
|
||||
session.setPrompt(reqVO.getPrompt());
|
||||
if (reqVO.getAttachments() != null) {
|
||||
session.setAttachments(reqVO.getAttachments());
|
||||
}
|
||||
session.setStatus(StudioSessionStatusEnum.GENERATING.getStatus());
|
||||
studioSessionMapper.updateById(session);
|
||||
|
||||
// 3) 落任务链(status=1 生成中),后续回填 aigc_task_id
|
||||
StudioTaskChainDO chain = new StudioTaskChainDO();
|
||||
chain.setSessionId(session.getId());
|
||||
chain.setCreatorUserId(userId);
|
||||
chain.setGameId(session.getGameId());
|
||||
chain.setStatus(StudioSessionStatusEnum.GENERATING.getStatus());
|
||||
chain.setProgress(0);
|
||||
studioTaskChainMapper.insert(chain);
|
||||
|
||||
// 4) 委托 aigc 提交生成(带 prompt/templateId/gameId/userId;attachments 不透传,R7)。同步直写无 MQ(D4)
|
||||
AigcGenerateReqDTO req = new AigcGenerateReqDTO();
|
||||
req.setUserId(userId);
|
||||
req.setPrompt(reqVO.getPrompt());
|
||||
req.setTemplateId(session.getTemplateId());
|
||||
req.setGameId(session.getGameId());
|
||||
AigcGenerateRespDTO resp;
|
||||
try {
|
||||
resp = aigcApi.submitGenerate(req).getCheckedData();
|
||||
} catch (Exception e) {
|
||||
// aigc 调用异常 → 链态置失败 3 + 会话失败,抛 studio 错误码(中文日志,traceId 由 aigc 内部生成此处暂无)
|
||||
markChainFailed(chain, session);
|
||||
log.error("[generate] 生成委托 aigc 失败 sessionId={} userId={}", session.getId(), userId, e);
|
||||
throw exception(STUDIO_AIGC_DELEGATE_FAIL);
|
||||
}
|
||||
if (resp == null || resp.getTaskId() == null) {
|
||||
markChainFailed(chain, session);
|
||||
log.error("[generate] 生成委托 aigc 返回空 taskId sessionId={} userId={}", session.getId(), userId);
|
||||
throw exception(STUDIO_AIGC_DELEGATE_FAIL);
|
||||
}
|
||||
// 5) 回填 aigc_task_id
|
||||
chain.setAigcTaskId(resp.getTaskId());
|
||||
studioTaskChainMapper.updateById(chain);
|
||||
log.info("[generate] 生成已委托 aigc taskChainId={} aigcTaskId={} traceId={} sessionId={} userId={}",
|
||||
chain.getId(), resp.getTaskId(), resp.getTraceId(), session.getId(), userId);
|
||||
return chain;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudioTaskChainDO getTaskChain(Long taskChainId, Long userId) {
|
||||
// 1) 校验任务链存在 + 归属
|
||||
StudioTaskChainDO chain = validateTaskChainOwner(taskChainId, userId);
|
||||
// 2) 未委托 aigc(aigc_task_id 为空,理论不可达)→ 直接返回当前态
|
||||
if (chain.getAigcTaskId() == null) {
|
||||
return chain;
|
||||
}
|
||||
// 3) 委托 aigc getTask 取进度/状态(透传,非 MQ,D4)
|
||||
AigcTaskRespDTO task;
|
||||
try {
|
||||
task = aigcApi.getTask(chain.getAigcTaskId(), userId).getCheckedData();
|
||||
} catch (Exception e) {
|
||||
// 轮询 aigc 异常:不改链态(保留上次态供前端继续轮询),仅记日志后返回当前态
|
||||
log.warn("[getTaskChain] 轮询 aigc 异常,保留链态 taskChainId={} aigcTaskId={}",
|
||||
taskChainId, chain.getAigcTaskId(), e);
|
||||
return chain;
|
||||
}
|
||||
if (task == null) {
|
||||
return chain;
|
||||
}
|
||||
// 4) 映射 aigc 态 → 任务链态 + 会话态
|
||||
chain.setProgress(task.getProgress() != null ? task.getProgress() : chain.getProgress());
|
||||
if (Objects.equals(AigcTaskStatusEnum.SUCCEEDED.getStatus(), task.getStatus())) {
|
||||
// aigc 成功:链完成 + 回填 version_id(版本权威归 project,此处仅回填引用)+ 会话完成
|
||||
chain.setStatus(StudioSessionStatusEnum.DONE.getStatus());
|
||||
chain.setProgress(100);
|
||||
chain.setVersionId(task.getVersionId());
|
||||
updateSessionStatus(chain.getSessionId(), StudioSessionStatusEnum.DONE.getStatus());
|
||||
log.info("[getTaskChain] 生成完成 taskChainId={} aigcTaskId={} versionId={}",
|
||||
taskChainId, chain.getAigcTaskId(), task.getVersionId());
|
||||
} else if (Objects.equals(AigcTaskStatusEnum.FAILED.getStatus(), task.getStatus())
|
||||
|| Objects.equals(AigcTaskStatusEnum.TIMED_OUT.getStatus(), task.getStatus())) {
|
||||
// aigc 失败/超时:链失败 + 会话失败
|
||||
chain.setStatus(StudioSessionStatusEnum.FAILED.getStatus());
|
||||
updateSessionStatus(chain.getSessionId(), StudioSessionStatusEnum.FAILED.getStatus());
|
||||
log.warn("[getTaskChain] 生成失败 taskChainId={} aigcTaskId={} reason={}",
|
||||
taskChainId, chain.getAigcTaskId(), task.getFailureReason());
|
||||
}
|
||||
// queued/running/canceled:保持生成中链态,仅回写 progress
|
||||
studioTaskChainMapper.updateById(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudioTaskChainDO regenerate(Long taskChainId, Long userId) {
|
||||
// 1) 校验原任务链存在 + 归属
|
||||
StudioTaskChainDO origin = validateTaskChainOwner(taskChainId, userId);
|
||||
if (origin.getAigcTaskId() == null) {
|
||||
// 原链未委托 aigc,无可重试
|
||||
throw exception(STUDIO_AIGC_DELEGATE_FAIL);
|
||||
}
|
||||
// 2) 委托 aigc retryTask 重提生成
|
||||
AigcGenerateRespDTO resp;
|
||||
try {
|
||||
resp = aigcApi.retryTask(origin.getAigcTaskId(), userId).getCheckedData();
|
||||
} catch (Exception e) {
|
||||
log.error("[regenerate] 重生成委托 aigc 失败 taskChainId={} aigcTaskId={} userId={}",
|
||||
taskChainId, origin.getAigcTaskId(), userId, e);
|
||||
throw exception(STUDIO_AIGC_DELEGATE_FAIL);
|
||||
}
|
||||
if (resp == null || resp.getTaskId() == null) {
|
||||
log.error("[regenerate] 重生成委托 aigc 返回空 taskId taskChainId={} userId={}", taskChainId, userId);
|
||||
throw exception(STUDIO_AIGC_DELEGATE_FAIL);
|
||||
}
|
||||
// 3) 新建一条任务链(retry_of=原 id,记录重生成血缘),回填新 aigc_task_id;会话置生成中
|
||||
StudioTaskChainDO retry = new StudioTaskChainDO();
|
||||
retry.setSessionId(origin.getSessionId());
|
||||
retry.setCreatorUserId(userId);
|
||||
retry.setGameId(origin.getGameId());
|
||||
retry.setAigcTaskId(resp.getTaskId());
|
||||
retry.setStatus(StudioSessionStatusEnum.GENERATING.getStatus());
|
||||
retry.setProgress(0);
|
||||
retry.setRetryOf(origin.getId());
|
||||
studioTaskChainMapper.insert(retry);
|
||||
updateSessionStatus(origin.getSessionId(), StudioSessionStatusEnum.GENERATING.getStatus());
|
||||
log.info("[regenerate] 重生成已委托 aigc newTaskChainId={} retryOf={} aigcTaskId={} traceId={} userId={}",
|
||||
retry.getId(), origin.getId(), resp.getTaskId(), resp.getTraceId(), userId);
|
||||
return retry;
|
||||
}
|
||||
|
||||
// ============================== 私有校验/工具 ==============================
|
||||
|
||||
/**
|
||||
* 校验会话存在且归属当前用户
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param userId 当前用户 ID
|
||||
* @return 会话 DO
|
||||
*/
|
||||
private StudioSessionDO validateSessionOwner(Long sessionId, Long userId) {
|
||||
StudioSessionDO session = studioSessionMapper.selectById(sessionId);
|
||||
if (session == null) {
|
||||
throw exception(STUDIO_SESSION_NOT_EXISTS);
|
||||
}
|
||||
// 数据边界:创作者只能操作自己的会话(服务端强校验,不依赖前端)
|
||||
if (!Objects.equals(session.getCreatorUserId(), userId)) {
|
||||
throw exception(STUDIO_SESSION_NOT_OWNER);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验任务链存在且归属当前用户
|
||||
*
|
||||
* @param taskChainId 任务链 ID
|
||||
* @param userId 当前用户 ID
|
||||
* @return 任务链 DO
|
||||
*/
|
||||
private StudioTaskChainDO validateTaskChainOwner(Long taskChainId, Long userId) {
|
||||
StudioTaskChainDO chain = studioTaskChainMapper.selectById(taskChainId);
|
||||
if (chain == null) {
|
||||
throw exception(STUDIO_TASKCHAIN_NOT_EXISTS);
|
||||
}
|
||||
if (!Objects.equals(chain.getCreatorUserId(), userId)) {
|
||||
throw exception(STUDIO_SESSION_NOT_OWNER);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记任务链失败 + 会话失败(生成委托异常路径复用)
|
||||
*
|
||||
* @param chain 任务链 DO(已落库)
|
||||
* @param session 会话 DO
|
||||
*/
|
||||
private void markChainFailed(StudioTaskChainDO chain, StudioSessionDO session) {
|
||||
chain.setStatus(StudioSessionStatusEnum.FAILED.getStatus());
|
||||
studioTaskChainMapper.updateById(chain);
|
||||
updateSessionStatus(session.getId(), StudioSessionStatusEnum.FAILED.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 回写会话状态(仅状态位更新,避免整对象覆盖)
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param status 目标状态
|
||||
*/
|
||||
private void updateSessionStatus(Long sessionId, Integer status) {
|
||||
StudioSessionDO update = new StudioSessionDO();
|
||||
update.setId(sessionId);
|
||||
update.setStatus(status);
|
||||
studioSessionMapper.updateById(update);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 模块:studio(game-module-studio,Wave3 创作主链路最小版)| owner:studio 路
|
||||
-- 文件:V8.0.0__create_game_studio.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V8.0.1 DROP TABLE)
|
||||
-- 内容:studio 模块核心表 —— 创作会话 game_studio_session + 创作任务链 game_studio_taskchain + 资产槽位 game_studio_asset
|
||||
-- 职责(架构 Doc B §71-76):有状态创作工作台后端。编排「建草稿(委托 project createProject)→一句话生成(委托 aigc)
|
||||
-- →进度轮询→重生成→模板应用」。不跑 LLM(委托 aigc)、不持久化项目版本(交 project)、不做锁风裁决(调 compliance)。
|
||||
-- 边界纪律:
|
||||
-- - 项目/版本权威归 project(game_project / game_version);studio 仅持 game_id/version_id 引用,不在本模块建版本表。
|
||||
-- - 生成任务权威归 aigc(game_aigc_task);studio 任务链仅持 aigc_task_id 引用 + 自身链态,不在本模块重建生成状态机。
|
||||
-- - attachments 本波仅持久化(R7),不透传 aigc(aigc reqDTO 无 attachments 字段)。
|
||||
-- - MVP 进度推送走轮询(D4),非 MQ/SSE。
|
||||
-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id;
|
||||
-- 状态机用 tinyint,非法流转由 Service 校验,DO 层不承载;中文列注释;关键查询建索引、禁裸 select*。
|
||||
-- 错误码段:studio = 1-112-***-***
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_studio_session —— 创作会话表(有状态创作工作台主记录,对齐 P-CRT-10 进工作坊)
|
||||
-- 用途:创作者进工作坊 upsert 一条会话,承载草稿入参(title/prompt/templateId/attachments)+ 移交后的 game_id。
|
||||
-- 移交:createDraft 调 project ProjectApi.createProject 拿 gameId 回写本表 game_id(草稿移交 project,P-TPL-03)。
|
||||
-- attachments:JSON 持久化(本波仅持久化不透传 aigc,R7)。
|
||||
-- 状态机 status:0 草稿 → 1 生成中 → 2 完成 / 3 失败(StudioSessionStatusEnum;由 generate/getTask 透传 aigc 终态映射)。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_studio_session` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '创作会话 ID',
|
||||
`creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(业务字段;DataPermission:创作者只见自己会话。区别于审计列 creator)',
|
||||
`game_id` BIGINT NULL DEFAULT NULL COMMENT '移交后的游戏 ID(project.game_project.id);createDraft 调 createProject 后回写',
|
||||
`template_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '玩法模板 ID(透传 aigc,如 dodge/runner/clicker/puzzle/shooter)',
|
||||
`title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '作品标题(建草稿入参,透传 project createProject)',
|
||||
`prompt` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '一句话自然语言 Prompt(透传 aigc submitGenerate)',
|
||||
`attachments` JSON NULL DEFAULT NULL COMMENT '附件上下文(P-CRT-04,JSON 数组);本波仅持久化,P1 透传 aigc',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '会话状态机:0草稿 1生成中 2完成 3失败(StudioSessionStatusEnum;Service 校验流转)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_creator` (`creator_user_id`, `id`) COMMENT '创作者「我的创作会话」列表(DataPermission 边界 + 按 id 倒序)',
|
||||
KEY `idx_game` (`game_id`) COMMENT '按移交后的游戏 ID 反查会话'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作会话表(有状态创作工作台主记录;草稿入参 + 移交 game_id)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_studio_taskchain —— 创作任务链表(线性单步编排,对齐 P-CRT-01/09 一句话生成 + 重生成血缘)
|
||||
-- 用途:generate 时落一条任务链(status=1),回填 aigc_task_id;getTask 轮询透传 aigc 进度/状态映射链态。
|
||||
-- 引用边界:aigc_task_id 引用 aigc.game_aigc_task.id(生成权威归 aigc);version_id 引用 project.game_version.id
|
||||
-- (aigc succeeded 后回填,版本权威归 project);本表只持引用 + 自身链态,不重建生成状态机。
|
||||
-- 重生成血缘:retry_of 指向原任务链 id(P-CRT-09);regenerate 新建一条 retry_of=原 id 的链。
|
||||
-- 链态 status:0 初始 → 1 生成中 → 2 完成(回填 version_id) / 3 失败(透传 aigc failed/timed_out)。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_studio_taskchain` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '创作任务链 ID',
|
||||
`session_id` BIGINT NOT NULL COMMENT '所属创作会话 ID(= game_studio_session.id)',
|
||||
`creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(DataPermission 边界,归属校验用)',
|
||||
`aigc_task_id` BIGINT NULL DEFAULT NULL COMMENT '委托的 aigc 生成任务 ID(= aigc.game_aigc_task.id);generate 后回填,轮询据此查 aigc',
|
||||
`game_id` BIGINT NULL DEFAULT NULL COMMENT '关联游戏 ID(project.game_project.id,透传 aigc 用)',
|
||||
`version_id` BIGINT NULL DEFAULT NULL COMMENT '产物版本 ID(project.game_version.id);aigc succeeded 后由 getTask 回填',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '任务链状态机:0初始 1生成中 2完成 3失败(Service 校验流转,透传 aigc 终态映射)',
|
||||
`progress` INT NOT NULL DEFAULT 0 COMMENT '进度百分比 0-100(透传 aigc progress,供前端进度条 P-CRT-12)',
|
||||
`retry_of` BIGINT NULL DEFAULT NULL COMMENT '若为重生成,指向原任务链 ID(P-CRT-09 重生成血缘)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_session` (`session_id`, `id`) COMMENT '按会话查任务链(含重生成历史,按 id 倒序)',
|
||||
KEY `idx_aigc` (`aigc_task_id`) COMMENT '按 aigc 任务 ID 反查任务链(轮询回写映射)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作任务链表(线性单步编排;持 aigc_task_id/version_id 引用 + 链态)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_studio_asset —— 创作资产槽位表(单步出整包,对齐 P-CRT-02;MVP 仅落槽位 seam,不六路并发)
|
||||
-- 用途:单步生成产物的资产槽位记录(asset_type 槽位 + asset_url + 槽位状态)。
|
||||
-- 狠切(R7/草稿 scope):MVP 只建槽位表占位 seam,不实现六路并发资产生成;资产由 aigc 单步整包产出,studio 仅登记槽位。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_studio_asset` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '资产槽位 ID',
|
||||
`session_id` BIGINT NOT NULL COMMENT '所属创作会话 ID(= game_studio_session.id)',
|
||||
`asset_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '资产槽位类型(如 sprite/background/audio/icon;MVP 槽位占位)',
|
||||
`asset_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '资产 URL(OSS/MinIO;单步整包产出后登记)',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '槽位状态:0未生成 1已生成(Service 校验,MVP 单步直填)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_session_type` (`session_id`, `asset_type`) COMMENT '按会话+槽位类型查资产'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作资产槽位表(单步出整包槽位 seam;MVP 不六路并发)';
|
||||
@ -0,0 +1,296 @@
|
||||
package cn.wanxiang.game.module.studio.service.studio;
|
||||
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioDraftCreateReqVO;
|
||||
import cn.wanxiang.game.module.studio.controller.app.studio.vo.StudioGenerateReqVO;
|
||||
import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioSessionDO;
|
||||
import cn.wanxiang.game.module.studio.dal.dataobject.studio.StudioTaskChainDO;
|
||||
import cn.wanxiang.game.module.studio.dal.mysql.studio.StudioSessionMapper;
|
||||
import cn.wanxiang.game.module.studio.dal.mysql.studio.StudioTaskChainMapper;
|
||||
import cn.wanxiang.game.module.studio.enums.StudioSessionStatusEnum;
|
||||
import cn.wanxiang.game.module.aigc.api.AigcApi;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcGenerateReqDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcGenerateRespDTO;
|
||||
import cn.wanxiang.game.module.aigc.dto.AigcTaskRespDTO;
|
||||
import cn.wanxiang.game.module.aigc.enums.AigcTaskStatusEnum;
|
||||
import cn.wanxiang.game.module.project.api.ProjectApi;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import static cn.wanxiang.game.module.studio.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link StudioServiceImpl} 单元测试(纯 Mockito,不依赖 DB)
|
||||
*
|
||||
* 覆盖创作主链路编排核心:
|
||||
* - createDraft 委托 project-api(createProject 拿 gameId 回写会话;调用异常/空 gameId 抛错);
|
||||
* - generate 委托 aigc(落任务链 + submitGenerate 回填 aigc_task_id;attachments 不透传 aigc,R7);
|
||||
* - getTaskChain 状态流转(aigc succeeded→链完成回填 version_id;failed→链失败);
|
||||
* - regenerate 委托 aigc retryTask 新建 retry_of 任务链;
|
||||
* - 归属校验(创作者只见自己会话/任务链)。
|
||||
* mock BaseMapper.insert 用 any(*.class) 消歧(裸 any() 会因重载报错)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
class StudioServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private StudioServiceImpl studioService;
|
||||
|
||||
@Mock
|
||||
private StudioSessionMapper studioSessionMapper;
|
||||
@Mock
|
||||
private StudioTaskChainMapper studioTaskChainMapper;
|
||||
@Mock
|
||||
private ProjectApi projectApi;
|
||||
@Mock
|
||||
private AigcApi aigcApi;
|
||||
|
||||
private static final Long USER_ID = 99L;
|
||||
|
||||
// ============================== createDraft 委托 project-api ==============================
|
||||
|
||||
@Test
|
||||
void testCreateDraft_delegatesProjectCreate_andWritesBackGameId() {
|
||||
// project createProject 返回 gameId=2048
|
||||
when(projectApi.createProject(eq(USER_ID), eq("我的放置小镇"), eq("idle")))
|
||||
.thenReturn(CommonResult.success(2048L));
|
||||
// insert 回填会话 ID
|
||||
doAnswer(inv -> {
|
||||
StudioSessionDO d = inv.getArgument(0);
|
||||
d.setId(512L);
|
||||
return 1;
|
||||
}).when(studioSessionMapper).insert(any(StudioSessionDO.class));
|
||||
|
||||
StudioSessionDO session = studioService.createDraft(draftReq("我的放置小镇", "idle"), USER_ID);
|
||||
|
||||
// 断言:委托 project 建游戏拿 gameId 回写会话;落库 status=0 草稿
|
||||
assertEquals(2048L, session.getGameId());
|
||||
assertEquals(USER_ID, session.getCreatorUserId());
|
||||
assertEquals(StudioSessionStatusEnum.DRAFT.getStatus(), session.getStatus());
|
||||
verify(projectApi).createProject(USER_ID, "我的放置小镇", "idle");
|
||||
ArgumentCaptor<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/userId;attachments 不在 reqDTO(R7)
|
||||
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):回写会话 attachments,AigcGenerateReqDTO 无此字段(编译期即保证不透传)
|
||||
verify(studioSessionMapper, atLeastOnce()).updateById(any(StudioSessionDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerate_sessionNotOwner_throws() {
|
||||
// 会话属于他人 → 归属校验失败
|
||||
StudioSessionDO others = session(512L, 1L, 2048L, "idle");
|
||||
when(studioSessionMapper.selectById(512L)).thenReturn(others);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> studioService.generate(generateReq(512L, "p", null), USER_ID));
|
||||
assertEquals(STUDIO_SESSION_NOT_OWNER.getCode(), ex.getCode());
|
||||
verify(aigcApi, never()).submitGenerate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerate_aigcReturnsNull_marksFailed_andThrows() {
|
||||
StudioSessionDO session = session(512L, USER_ID, 2048L, "idle");
|
||||
when(studioSessionMapper.selectById(512L)).thenReturn(session);
|
||||
doAnswer(inv -> { ((StudioTaskChainDO) inv.getArgument(0)).setId(1000L); return 1; })
|
||||
.when(studioTaskChainMapper).insert(any(StudioTaskChainDO.class));
|
||||
// aigc 返回空 taskId → 链失败 + 抛错
|
||||
AigcGenerateRespDTO resp = new AigcGenerateRespDTO();
|
||||
when(aigcApi.submitGenerate(any())).thenReturn(CommonResult.success(resp));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> studioService.generate(generateReq(512L, "p", null), USER_ID));
|
||||
assertEquals(STUDIO_AIGC_DELEGATE_FAIL.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ============================== getTaskChain 状态流转 ==============================
|
||||
|
||||
@Test
|
||||
void testGetTaskChain_aigcSucceeded_chainDone_versionIdWrittenBack() {
|
||||
StudioTaskChainDO chain = taskChain(1000L, 512L, USER_ID, 8888L);
|
||||
when(studioTaskChainMapper.selectById(1000L)).thenReturn(chain);
|
||||
// aigc 成功,回填 version_id=4096
|
||||
AigcTaskRespDTO task = aigcTask(AigcTaskStatusEnum.SUCCEEDED.getStatus(), 100, 4096L);
|
||||
when(aigcApi.getTask(8888L, USER_ID)).thenReturn(CommonResult.success(task));
|
||||
|
||||
StudioTaskChainDO result = studioService.getTaskChain(1000L, USER_ID);
|
||||
|
||||
// 断言:链完成 + 回填 version_id + 会话置完成
|
||||
assertEquals(StudioSessionStatusEnum.DONE.getStatus(), result.getStatus());
|
||||
assertEquals(4096L, result.getVersionId());
|
||||
assertEquals(100, result.getProgress());
|
||||
ArgumentCaptor<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;
|
||||
}
|
||||
|
||||
}
|
||||
28
game-cloud/game-module-studio/pom.xml
Normal file
28
game-cloud/game-module-studio/pom.xml
Normal 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 聚合父 POM(groupId 复用 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 轮询非 MQ;attachments 本波仅持久化不透传 aigc。错误码段 = 1-112-***-***,Flyway = V8。
|
||||
</description>
|
||||
|
||||
</project>
|
||||
@ -29,6 +29,9 @@
|
||||
<module>game-module-ad</module>
|
||||
<!-- Wave2 变现域:trade(分账/结算/提现,钱财闭环下游,消费 ad 收入做 T+1 分账) -->
|
||||
<module>game-module-trade</module>
|
||||
<!-- Wave3:compliance(锁风门 Gate/分级/封禁,发布前合规)、studio(创作主链路编排,调 project/aigc) -->
|
||||
<module>game-module-compliance</module>
|
||||
<module>game-module-studio</module>
|
||||
</modules>
|
||||
|
||||
<name>${project.artifactId}</name>
|
||||
|
||||
@ -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.x;MySQL 需 flyway-mysql) -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
|
||||
@ -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-1,in-place 破坏性升级,无并存期):响应由 boolean 升级为结构化信封
|
||||
* {@link ProjectPublishResp}(admitted + gates[])。归属/草稿态为安全前置,失败由后端抛
|
||||
* 业务错误码(code≠0),经 request.ts 走 reject(前端 catch 失败态);发布前门禁聚合为 gates:
|
||||
* 全部通过则 admitted=true 并置 status=1 审核中;任一拦截则 admitted=false 且不改状态机。
|
||||
*
|
||||
* @param id 项目 ID
|
||||
* @param dto 发布请求体(versionId + launchZoneId)
|
||||
* @returns 发布门禁聚合结果(admitted + 逐项 gates)
|
||||
*/
|
||||
export function publish(id: number, dto: ProjectPublishReq): Promise<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,
|
||||
|
||||
@ -88,6 +88,11 @@ export interface InteractResult {
|
||||
/** 分享落地页 OG 元数据(feed.yaml ShareMetaRespVO) */
|
||||
export interface ShareMeta {
|
||||
gameId: number
|
||||
/**
|
||||
* 当前生效版本 ID(GAP-3,additive):供分享落地页「立即玩」携带 versionId 跳 /play/:gameId/:versionId。
|
||||
* 仅当游戏已发布(isGamePublished)时由后端回填;未发布则缺省(前端兜底空串)。
|
||||
*/
|
||||
versionId?: number
|
||||
/** 独立分享链接(含 utm/channel 渠道参数) */
|
||||
shareUrl: string
|
||||
ogTitle: string
|
||||
@ -196,6 +201,11 @@ export interface RuntimePackage {
|
||||
templateId: string
|
||||
/** GamePackage manifest URL */
|
||||
packageUrl: string
|
||||
/**
|
||||
* manifest.json 直取 URL(GAP-2,additive):宿主据此 fetch 并按 checksum(sha256) 校验后注入 iframe。
|
||||
* 后端由 packageUrl 同前缀派生(.../manifest.json)或单列回填;缺省时宿主退回 demo 兜底(见 GamePlayer.resolvePackage)。
|
||||
*/
|
||||
manifestUrl?: string
|
||||
/** 入口文件相对路径(manifest.entry) */
|
||||
entry: string
|
||||
/** 目标 SDK / Runtime 版本(semver) */
|
||||
@ -302,6 +312,37 @@ export interface ProjectPublishReq {
|
||||
launchZoneId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布前门禁单项结果(project.yaml GateResultVO,逐字段镜像后端)。
|
||||
* 一条 gate = 一项发布前检查(如 age 适龄提示 / compliance 合规扫描)。
|
||||
* pass=false 表示该项拦截,code/message 给出可回显给创作者的错误码与文案。
|
||||
*/
|
||||
export interface GateResult {
|
||||
/** 门禁键(稳定标识,前端据此定位/高亮);如 status/title/cover/version/age/zone/compliance */
|
||||
key: string
|
||||
/** 门禁中文标签(展示用) */
|
||||
label?: string
|
||||
/** 是否通过:true 通过 / false 拦截 */
|
||||
pass: boolean
|
||||
/** 拦截时的错误码字符串(pass=true 时缺省) */
|
||||
code?: string
|
||||
/** 拦截时的提示文案(pass=true 时缺省) */
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交发布响应(project.yaml ProjectPublishRespVO,发布前门禁聚合结果)。
|
||||
* - admitted=true:全部 gate 通过,状态机已置「审核中」;
|
||||
* - admitted=false:至少一条 gate 拦截,状态机未改动(无副作用),创作者据 gates 修复后重提。
|
||||
* 注意:归属/草稿态等「安全前置」失败仍由后端抛业务错误码(不进本结构),前端走 catch 失败态。
|
||||
*/
|
||||
export interface ProjectPublishResp {
|
||||
/** 是否准入(全部门禁通过=true;任一拦截=false 且不改状态机) */
|
||||
admitted: boolean
|
||||
/** 发布前门禁逐项结果(含通过与拦截项,供前端逐条回显) */
|
||||
gates: GateResult[]
|
||||
}
|
||||
|
||||
/* ============================== telemetry.yaml ============================== */
|
||||
|
||||
/**
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
* ----------------------------------------------------------------------------
|
||||
* 职责(spec §3.4 / AC5 / AC6):
|
||||
* 1. iframe 沙箱:设 sandbox="allow-scripts allow-same-origin"(security §1.1)。
|
||||
* 2. 取包:传入 manifest 则直接用;否则按 versionId 调 runtime.getPackage(契约 #1)。
|
||||
* 3. 注入:SDK + Runtime + demo → iframe srcdoc 内联(inject.ts)。
|
||||
* 2. 取包(GAP-2):传入 manifest 则直接用;否则按 versionId 调 runtime.getPackage(契约 #1)取清单,
|
||||
* 若清单含 manifestUrl 则 fetch 并按 checksum(sha256) 校验后注入真包;无 manifestUrl 才退回 demo 兜底。
|
||||
* 3. 注入:SDK + Runtime + GamePackage → iframe srcdoc 内联(inject.ts)。
|
||||
* 4. 挂 bridge:postMessage 收发 + origin/schema 双校验 + ad/pay requestId 配对。
|
||||
* 5. 三容器(当前/上/下)预加载占位(竖屏刷流体验占位,骨架阶段为视觉占位)。
|
||||
* 6. ad/pay 桩弹层:点击模拟激励视频/支付,回调 rewarded:true / paid:true。
|
||||
@ -29,7 +30,7 @@ import {
|
||||
type RuntimePackageRespVO,
|
||||
} from './contract';
|
||||
import { HostBridge, buildOriginAllowlist, type RejectReason } from './bridge';
|
||||
import { buildIframeSrcdoc, buildDemoPackage } from './inject';
|
||||
import { buildIframeSrcdoc, buildDemoPackage, fetchAndVerifyManifest } from './inject';
|
||||
import { getRuntimePackage } from './runtimeApi';
|
||||
|
||||
// —— props(spec §3.4)——
|
||||
@ -96,30 +97,52 @@ const containers = ['prev', 'current', 'next'] as const;
|
||||
* 取包 + 注入主流程
|
||||
* ========================================================================== */
|
||||
|
||||
/** 取游戏包:优先 props.manifest;否则按 versionId 走契约端点;失败给 demo 兜底(骨架自测) */
|
||||
/**
|
||||
* 取游戏包(GAP-2):
|
||||
* 1) 优先 props.manifest(创作预览直传,不走网络)。
|
||||
* 2) 否则按 versionId 调 runtime.getPackage 取「运行包清单」(契约 #1 RuntimePackageRespVO):
|
||||
* - 清单含 manifestUrl → fetch manifest.json 并按 checksum(sha256) 完整性校验后注入真包(T-RT-15);
|
||||
* 校验/拉取失败 → emit('error') 并退回 demo 兜底(不白屏)。
|
||||
* - 清单无 manifestUrl(如 mock 阶段后端尚未回填)→ 退回 demo 兜底,并标注这是「无 manifestUrl 兜底」。
|
||||
* 3) versionId 非法 → demo 兜底。
|
||||
*/
|
||||
async function resolvePackage(): Promise<GamePackage> {
|
||||
// 1) 直接传入的 manifest(创作预览常用)
|
||||
if (props.manifest) return props.manifest;
|
||||
|
||||
// 2) 按 versionId 调 runtime.getPackage(契约 #1 RuntimePackageRespVO)
|
||||
// 注:契约返回的是「运行包清单」(manifest URL 等),骨架阶段没有真实 OSS 包可下载,
|
||||
// 故此处取到清单后,仍用 demo 游戏包驱动 Runtime(assets 为空的 clicker),
|
||||
// 保证本地 mock 链路可真跑通;真实联调时改为按 packageUrl 拉取并解析为 GamePackage。
|
||||
const vid = Number(props.versionId);
|
||||
if (Number.isFinite(vid)) {
|
||||
let respVO: RuntimePackageRespVO;
|
||||
try {
|
||||
const fetcher = props.fetchPackage ?? getRuntimePackage;
|
||||
const respVO: RuntimePackageRespVO = await fetcher(vid, props.mode);
|
||||
// 清单拿到即认为取包成功;用 respVO 的 trace/template 充实 demo 包元信息。
|
||||
const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value);
|
||||
demo.templateId = respVO.templateId || demo.templateId;
|
||||
return demo;
|
||||
respVO = await fetcher(vid, props.mode);
|
||||
} catch (e) {
|
||||
// 取包失败:抛给上层 error,并以 demo 兜底(骨架阶段不白屏,便于自测链路)
|
||||
const message = e instanceof Error ? e.message : '取包失败';
|
||||
// 取清单失败:抛给上层 error,并以 demo 兜底(不白屏,便于自测链路)
|
||||
const message = e instanceof Error ? e.message : '取包清单失败';
|
||||
emit('error', { message });
|
||||
return buildDemoPackage(props.gameId, props.versionId, traceId.value);
|
||||
}
|
||||
|
||||
// 2a) 清单含 manifestUrl:拉取真包 + checksum 完整性校验后注入
|
||||
if (respVO.manifestUrl) {
|
||||
try {
|
||||
return await fetchAndVerifyManifest(respVO.manifestUrl, respVO.checksum);
|
||||
} catch (e) {
|
||||
// 拉取/校验失败:上报 error,退回 demo 兜底(去无条件 demo,仅失败时兜底)
|
||||
const message = e instanceof Error ? e.message : 'manifest 取包/校验失败';
|
||||
emit('error', { message });
|
||||
const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value);
|
||||
demo.templateId = respVO.templateId || demo.templateId;
|
||||
return demo;
|
||||
}
|
||||
}
|
||||
|
||||
// 2b) 清单无 manifestUrl(后端尚未回填 / mock 阶段):退回 demo 兜底,用清单元信息充实
|
||||
// —— GAP-2 约定:仅在无 manifestUrl 时兜底(去掉原「无条件 demo」逻辑)。
|
||||
const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value);
|
||||
demo.templateId = respVO.templateId || demo.templateId;
|
||||
return demo;
|
||||
}
|
||||
|
||||
// 3) versionId 非法:demo 兜底
|
||||
|
||||
@ -198,6 +198,12 @@ export interface RuntimePackageRespVO {
|
||||
versionId: number;
|
||||
templateId: string;
|
||||
packageUrl: string;
|
||||
/**
|
||||
* manifest.json 直取 URL(GAP-2,additive,对齐 runtime.yaml RuntimePackageRespVO.manifestUrl)。
|
||||
* 宿主据此 fetch 解析为 GamePackage(#4),并按本结构 checksum 做 sha256 完整性校验后注入 iframe(T-RT-15)。
|
||||
* 后端由 packageUrl 同前缀派生或单列回填;缺省时宿主退回 demo 兜底(见 GamePlayer.resolvePackage)。
|
||||
*/
|
||||
manifestUrl?: string;
|
||||
entry: string;
|
||||
runtimeVersion: string;
|
||||
preloadPolicy: 'eager' | 'lazy';
|
||||
|
||||
@ -132,6 +132,72 @@ function safeJson(obj: unknown): string {
|
||||
return JSON.stringify(obj).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串的 sha256(hex 小写)。用 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 仅作为数据注入 iframe(inject 内 JSON.stringify),
|
||||
* 不会被当作可执行内容求值。manifestUrl 的同源/CORS 由 runtime 下发 sandbox.allowOrigins + OSS CORS 约束保障。
|
||||
*
|
||||
* @param manifestUrl manifest.json 直取 URL(runtime 清单回填)
|
||||
* @param expectedChecksum 期望的整包 sha256(hex,来自运行包清单 checksum)
|
||||
* @returns 校验通过的 GamePackage
|
||||
* @throws 网络失败 / 非 JSON / 结构非法 / checksum 不匹配 / 无 crypto.subtle
|
||||
*/
|
||||
export async function fetchAndVerifyManifest(
|
||||
manifestUrl: string,
|
||||
expectedChecksum?: string,
|
||||
): Promise<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。
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -5,15 +5,25 @@
|
||||
* POST /app-api/project → Long(新建项目 ID)
|
||||
* GET /app-api/project/:id → ProjectRespVO
|
||||
* PUT /app-api/project/:id/draft → boolean
|
||||
* POST /app-api/project/:id/publish → boolean
|
||||
* POST /app-api/project/:id/publish → ProjectPublishRespVO{admitted, gates[]}(GAP-1 结构化门禁聚合)
|
||||
*
|
||||
* 注:publish 响应在契约中仅为 CommonResultBoolean(无结构化门禁清单字段),
|
||||
* 故 mock 严格返回 boolean;“发布门禁失败项清单”能力为契约缺口,已在返回 contractGaps 上报,
|
||||
* 不在此私造响应字段。mock 以 launchZoneId 是否传入做最小门禁演示(缺失→业务错误码)。
|
||||
* 注(GAP-1,已落地):publish 响应已由 boolean 升级为结构化信封 ProjectPublishRespVO{admitted, gates[]},
|
||||
* 与后端 project.yaml 同批迁移、无并存期。mock 据此返回 {admitted, gates}:
|
||||
* - 归属/草稿态/必填缺失 = 安全前置,抛业务错误码(对齐后端「失败抛异常,不进 gates」);
|
||||
* - 适龄提示(age) = 业务可恢复门禁,未声明则 admitted=false 且该 gate.pass=false(不抛异常)。
|
||||
* gate.key 与后端一致(status/title/cover/version/age/zone/compliance);compliance 由 Phase C 后端注入,mock 不私造。
|
||||
*/
|
||||
import type { MockRoute, MockRequest } from './shared'
|
||||
import { MockError, nowISO, seededInt } from './shared'
|
||||
import type { Project, ProjectCreateReq, ProjectDraftReq, ProjectPublishReq, ProjectStatus } from '../api/types'
|
||||
import type {
|
||||
GateResult,
|
||||
Project,
|
||||
ProjectCreateReq,
|
||||
ProjectDraftReq,
|
||||
ProjectPublishReq,
|
||||
ProjectPublishResp,
|
||||
ProjectStatus,
|
||||
} from '../api/types'
|
||||
|
||||
/** demo 版本 ID(与全链路对齐) */
|
||||
const DEMO_VERSION_ID = 9001
|
||||
@ -122,23 +132,50 @@ function draftHandler(req: MockRequest): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** POST /app-api/project/:id/publish */
|
||||
function publishHandler(req: MockRequest): boolean {
|
||||
/**
|
||||
* POST /app-api/project/:id/publish(GAP-1:返回结构化门禁聚合 ProjectPublishResp)
|
||||
*
|
||||
* 安全前置(抛业务错误码,不进 gates,对齐后端「失败抛异常」):
|
||||
* - 项目不存在 / 非草稿态 / 必填 versionId+launchZoneId 缺失。
|
||||
* 业务可恢复门禁(收集为 gates,不抛异常,由 admitted 表达整体放行/拦截):
|
||||
* - age 适龄提示:未声明 ageRating 则该门禁 pass=false、admitted=false、不改状态机。
|
||||
* 全部门禁通过 → 置 status=1 审核中并 admitted=true。
|
||||
*/
|
||||
function publishHandler(req: MockRequest): ProjectPublishResp {
|
||||
ensureSeed()
|
||||
const id = Number(req.params.id)
|
||||
const p = projects.get(id)
|
||||
if (!p) throw new MockError(1100002001, '项目不存在')
|
||||
// 贴契约:仅 status=0 可提交发布
|
||||
// 安全前置:仅 status=0 可提交发布(抛异常,不进 gates)
|
||||
if (p.status !== 0) throw new MockError(1100003002, '仅草稿可提交发布')
|
||||
const body = (req.body ?? {}) as Partial<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 之前) */
|
||||
|
||||
@ -36,6 +36,13 @@ function packageHandler(req: MockRequest): RuntimePackage {
|
||||
// 模板:clicker(与 demo 点击计数玩法一致)
|
||||
templateId: 'clicker',
|
||||
packageUrl: `/games/${gameId}/versions/${versionId}/manifest.json`,
|
||||
// GAP-2:mock 刻意「不」回填 manifestUrl。
|
||||
// 宿主 GamePlayer.resolvePackage 对「无 manifestUrl」走 demo 兜底(assets 为空的 clicker),
|
||||
// 保证本地 mock 链路无需真实 OSS 即可跑通;而 manifestUrl 的 fetch+sha256 校验路径在
|
||||
// 隔离 staging 联调(runtime 回填真实 manifestUrl + OSS CORS)时验证。
|
||||
// 若在此填一个 mock 中间件(仅拦 /app-api、/admin-api)无法服务的 /games/... 路径,
|
||||
// 只会触发取包失败兜底,污染 error 事件,故不填。详见返回 openIssues。
|
||||
// manifestUrl: undefined,
|
||||
// 入口约定为 'demo':A2 Runtime 据此加载内置 demo 游戏(非真实文件路径,链路约定值)
|
||||
entry: 'demo',
|
||||
runtimeVersion: '1.0.0',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -2,18 +2,18 @@
|
||||
/**
|
||||
* Publish.vue —— 发布门禁页(创作区 /project/:id/publish)。
|
||||
*
|
||||
* 闭环职责(spec §3.2 / B3 / §6 边缘失败):
|
||||
* 闭环职责(spec §3.2 / B3 / §6 边缘失败 / GAP-1):
|
||||
* - 进入即加载项目详情(loadDetail)+ 专区列表(feedApi.getZones),用于发布去向选择。
|
||||
* - 展示发布前 7 门禁“预检清单”(computeGates,纯客户端,见 publish-gates.ts 顶部契约说明)。
|
||||
* - 提交发布 → projectStore.publish(id, {versionId, launchZoneId}) → project.publish(POST /publish)。
|
||||
* - 成功(服务端返回 boolean=true)→ 进入“已提交审核”成功态提示。
|
||||
* - 失败(服务端错误码/校验未过)→ 列出失败项(服务端 msg 为权威失败原因)+ 可“返回修改”。
|
||||
* - 展示发布前客户端「预检清单」(computeGates,纯客户端,仅辅助引导,非权威)。
|
||||
* - 提交发布 → projectStore.publish(id, {versionId, launchZoneId}) → 返回结构化 {admitted, gates}。
|
||||
* - admitted=true → 进入「已提交审核」成功态。
|
||||
* - admitted=false → 展示服务端逐项门禁结果(失败项高亮 label+message),创作者据此修复后重提。
|
||||
* - 安全前置失败(后端抛错误码,如非草稿态/缺必填)→ catch 走失败态,msg 为权威原因。
|
||||
*
|
||||
* ⚠ 契约纪律(最高优先级):
|
||||
* - 契约 publish 响应仅 CommonResultBoolean,**无结构化 7 门禁逐项结果字段**。
|
||||
* - 本页“7 门禁清单”是客户端预检(资料完整性自检),用于引导创作者;
|
||||
* **权威判定以服务端 boolean / 错误码为准**,绝不据 mock 私造契约外的门禁响应结构。
|
||||
* - 该缺口已在返回 contractGaps 上报,待主 agent 决策(如需后端补结构化门禁响应)。
|
||||
* 契约纪律(GAP-1 已落地,最高优先级):
|
||||
* - publish 响应已升级为结构化 ProjectPublishResp{admitted, gates[]},**服务端 gates 为权威判定**。
|
||||
* - 客户端 computeGates 预检退为「辅助提示」:仅在提交前引导创作者补资料,不作准入裁决依据。
|
||||
* - 渲染服务端结果时严格用 gates 的 key/label/pass/message,不私造契约外字段。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@ -22,7 +22,7 @@ import { useProjectStore } from '../../store/project'
|
||||
import { feedApi } from '../../api'
|
||||
import { LoadingBar, AppButton, EmptyState } from '../../components'
|
||||
import { computeGates } from './publish-gates'
|
||||
import type { Zone } from '../../api/types'
|
||||
import type { GateResult, Zone } from '../../api/types'
|
||||
|
||||
/** 路由 props:true 注入 :id */
|
||||
const props = defineProps<{ id: string }>()
|
||||
@ -47,22 +47,29 @@ const launchZoneId = ref<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 即 true;ok=false 视作未受理
|
||||
// 门禁拦截:未改状态机,列出服务端失败项供创作者修复后重提
|
||||
phase.value = 'failed'
|
||||
failReason.value = '发布未被受理,请稍后重试'
|
||||
const firstFail = serverGates.value.find((g) => !g.pass)
|
||||
failReason.value = firstFail?.message || '发布前门禁未通过,请按下方提示修复后重提'
|
||||
}
|
||||
} catch (err) {
|
||||
// 服务端业务错误码:msg 为权威失败原因(request.ts 已 Toast,这里落到失败态展示)
|
||||
// 安全前置抛错(归属/草稿态/缺必填):msg 为权威失败原因(request.ts 已 Toast)
|
||||
serverGates.value = []
|
||||
phase.value = 'failed'
|
||||
failReason.value = (err as Error)?.message || '发布前检查未通过'
|
||||
} finally {
|
||||
@ -162,6 +176,7 @@ function goList() {
|
||||
function retry() {
|
||||
phase.value = 'idle'
|
||||
failReason.value = ''
|
||||
serverGates.value = []
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -205,19 +220,28 @@ onMounted(() => {
|
||||
</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'">
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -5,12 +5,12 @@
|
||||
* 职责(spec §3.1 / AC3):
|
||||
* 1. 拉 OG 元数据:feed.getShareMeta(gameId, channel)(feed.yaml /feed/share/{gameId})
|
||||
* → 展示封面(ogImage)/标题(ogTitle)/简介(ogDescription)。
|
||||
* 2.「立即玩」→ 跳 /play/:gameId 进入试玩(玩家闭环回流)。
|
||||
* 2.「立即玩」→ 跳 /play/:gameId/:versionId 进入试玩(玩家闭环回流)。
|
||||
* 3. 渠道归因:channel 从 URL query 读(utm_source 等),透传给 getShareMeta 回显并埋点。
|
||||
*
|
||||
* 注意(契约观察):feed.yaml ShareMetaRespVO 不含 versionId,故落地页无法从分享契约直接拿到
|
||||
* 「即玩版本」。本页据此跳 /play/:gameId(不带 versionId);试玩页/宿主在 mock 阶段以 demo 包兜底,
|
||||
* 真实联调需由 play 路由侧或 feed 契约补 versionId。此为契约缺口,已上报,不在本页私造字段。
|
||||
* GAP-3 已落地:feed.yaml ShareMetaRespVO 新增 versionId(已发布游戏回填,未发布缺省)。
|
||||
* 本页「立即玩」携带 meta.versionId 跳 /play/:gameId/:versionId(缺省兜底空串,由可选路由参吸收),
|
||||
* 使宿主可直接按版本取包,不再单靠 demo 兜底。
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
@ -71,14 +71,15 @@ onMounted(() => {
|
||||
|
||||
/**
|
||||
* 立即玩:跳试玩页(玩家闭环回流)。
|
||||
* 受限于 ShareMeta 无 versionId,仅带 gameId(mock 阶段宿主 demo 兜底)。
|
||||
* GAP-3:携带 ShareMeta.versionId 跳 /play/:gameId/:versionId(已发布游戏由后端回填);
|
||||
* 未发布或缺省时 versionId 兜底空串,由可选路由参 /play/:gameId/:versionId? 吸收,宿主再退回兜底。
|
||||
*/
|
||||
function onPlay(): void {
|
||||
const gid = numGameId()
|
||||
if (!gid) return
|
||||
track('share', { action: 'click_play', channel: meta.value?.channel }, { gameId: gid })
|
||||
// /play/:gameId/:versionId? —— 此处 versionId 缺省(契约缺口,见文件头说明)
|
||||
router.push(`/play/${gid}`)
|
||||
const vid = meta.value?.versionId ?? ''
|
||||
router.push(`/play/${gid}/${vid}`)
|
||||
}
|
||||
|
||||
/** 返回游戏流(落地页二级入口) */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user