diff --git a/CLAUDE.md b/CLAUDE.md index 1dc49795..20e3c390 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,8 @@ MVP 目标:交付一个**种子用户可试用的全链路闭环**——创作 | **game-admin** | 管理后台前端(运营/管理员用) | Vue3 + Element Plus(yudao-ui-admin-vue3 fork) | | **game-studio** | 产品端前端(创作者 + 玩家用) | Vue3 + Vant + 自研轻量 Canvas Runtime(<15KB, Tier1) + WanxiangGameSDK;3D/独立App 为远期分层(详见 [.agents tech-decisions §1.1](.agents/knowledge/tech-decisions.md)) | +> **注(命名区分)**:Wave3 后端 game-cloud 内新增 `studio` 业务模块(创作主链路编排,错误码段 112 / Flyway V8),与上表**产品端前端仓 `game-studio`** 是两个东西——前者是后端编排模块,后者是 Vue3 前端仓,勿混淆。 + ### 3.2 当前仓库(文档仓)目录结构 ``` diff --git a/contracts/README.md b/contracts/README.md index a2be96b4..133cae9e 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -21,7 +21,8 @@ > **M0 八类契约已锁**(JSON/YAML/Schema 语法校验通过):#1 project.yaml · #2 V1.0.0__…sql · #3 sdk-interface.d.ts · #4 game-package · #5 events · #6 dify-workflow-io · #7 ad-slot · #8 prompts/。 > **Wave1 脊柱 4 模块契约已补并锁**(aigc/runtime/feed/telemetry,2026-06-08):经契约先行起草 + 一致性评审 + 收口(§2.1)+ 主 agent 校验(YAML 语法/裸 select* 0/错误码段 101-104 独占/Flyway V2-5 唯一)。 -> **Wave2 变现契约已补并锁**(ad/trade,2026-06-08):`ad.yaml`+`trade.yaml`(API #1)+ `V6.0.0`/`V7.0.0`(DB #2),消费既有 #7 ad-slot / #5 events 不重造;经主 agent 校验(YAML 语法/错误码 111·106 独占/Flyway V6-7 唯一/金额用「分」/资金幂等键+状态机/ad↔trade Feign seam 一致);ad 计费 `uk_trace` 由主 agent 收紧为 `(trace_id,event_type,tenant_id)` 防 reward 被 impression 静默吞掉。**pay=复用 yudao-pay 后置**(收单 P1)。其余模块(community/ip/compliance/biz)待后续波次补。 +> **Wave2 变现契约已补并锁**(ad/trade,2026-06-08):`ad.yaml`+`trade.yaml`(API #1)+ `V6.0.0`/`V7.0.0`(DB #2),消费既有 #7 ad-slot / #5 events 不重造;经主 agent 校验(YAML 语法/错误码 111·106 独占/Flyway V6-7 唯一/金额用「分」/资金幂等键+状态机/ad↔trade Feign seam 一致);ad 计费 `uk_trace` 由主 agent 收紧为 `(trace_id,event_type,tenant_id)` 防 reward 被 impression 静默吞掉。**pay=复用 yudao-pay 后置**(收单 P1)。 +> **Wave3 跨主线收口(进行中)**:新增 `studio`(创作主链路最小版编排,错误码 112 / Flyway V8)+ `compliance`(锁风门 Gate,发布前合规扫描)两模块契约。Phase A 已收口 project 侧:publish 唯一化(返回 admitted+gates 门禁聚合)、ProjectApi seam 定稿(getCurrentVersionId GAP-3 / createProject 写类 RPC)、GAP-4 把 `events.schema.json` 信封 envelope/user 由 snake_case 统一为 camelCase(与 telemetry.yaml/前端 SDK 对齐)。compliance gate 在 project.submitPublish 内仅留 Phase C 接入 seam(TODO 注释),尚未接 ComplianceGateApi。其余模块(community/ip/biz)待后续波次补。 ``` contracts/ @@ -33,7 +34,8 @@ contracts/ │ ├── feed.yaml # Wave1 游戏流/双轨专区(已锁) │ ├── telemetry.yaml # Wave1 遥测/聚合回灌(已锁) │ ├── ad.yaml # Wave2 广告引擎(已锁;含 ad↔trade Feign seam) -│ └── trade.yaml # Wave2 分账/结算/提现(已锁) +│ ├── trade.yaml # Wave2 分账/结算/提现(已锁) +│ └── studio.yaml # Wave3 创作主链路最小版(编排 project/aigc/runtime) ├── db-schemas/ # #2 Flyway 迁移(本目录=授权源;执行副本在 game-cloud/yudao-server/src/main/resources/db/migration/,Flyway 校验和敏感须保持 diff 一致) │ ├── V1.0.0__create_game_project.sql │ ├── V2.0.0__create_game_aigc.sql # aigc(每模块独占主版本号) @@ -41,7 +43,8 @@ contracts/ │ ├── V4.0.0__create_game_feed.sql │ ├── V5.0.0__create_game_telemetry.sql │ ├── V6.0.0__create_game_ad.sql # ad(广告位 + 收入台账,Wave2) -│ └── V7.0.0__create_game_trade.sql # trade(账户 + 流水 + 提现,Wave2) +│ ├── V7.0.0__create_game_trade.sql # trade(账户 + 流水 + 提现,Wave2) +│ └── V8.0.0__create_game_studio.sql # studio(创作主链路编排状态,Wave3) ├── game-package.schema.json # #4 GamePackage 清单(已落地) ├── events.schema.json # #5 telemetry 事件 v1(已落地) ├── sdk-interface.d.ts # #3 已锁(SDK API + postMessage 协议) @@ -73,4 +76,4 @@ URL 格式:`/{端前缀}/{模块}/{资源}/{动作}`。端前缀 `/app-api`·` ## 四、错误码段(每模块独占,来自工程规范 §1.3) -`1-{模块段}-{业务}-{细分}`:project=100 / aigc=101 / runtime=102 / feed=103 / telemetry=104 / pay=105 / trade=106 / community=107 / ip=108 / compliance=109 / biz=110 / ad=111。新增模块在 `-api` 错误码常量类登记,禁止重叠。 +`1-{模块段}-{业务}-{细分}`:project=100 / aigc=101 / runtime=102 / feed=103 / telemetry=104 / pay=105 / trade=106 / community=107 / ip=108 / compliance=109 / biz=110 / ad=111 / studio=112。新增模块在 `-api` 错误码常量类登记,禁止重叠。 diff --git a/contracts/api-schemas/project.yaml b/contracts/api-schemas/project.yaml index 7d0a5d86..322163ca 100644 --- a/contracts/api-schemas/project.yaml +++ b/contracts/api-schemas/project.yaml @@ -78,8 +78,10 @@ paths: /app-api/project/{id}/publish: post: tags: [app-project] - summary: 提交发布(发布前检查清单 7 门禁 → 进审核 BPM;统一发布编排 compliance→runtime→feed 失败回滚 ←G6) - description: 服务端校验状态机(仅 status=0 可提交),通过发布前检查后置 status=1 审核中。 + summary: 提交发布(发布前门禁聚合 → 进审核 BPM;统一发布编排 compliance→runtime→feed 失败回滚 ←G6) + description: >- + 归属/草稿态为安全前置,失败抛异常(code≠0)。发布前门禁聚合为 gates:全部通过则 admitted=true 并置 status=1 审核中; + 任一拦截则 admitted=false 且不改状态机(无副作用),创作者据 gates 修复后重提。 parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } requestBody: @@ -89,10 +91,10 @@ paths: schema: { $ref: '#/components/schemas/ProjectPublishReqVO' } responses: '200': - description: 成功(含发布前检查项结果) + description: 成功(含发布前门禁聚合结果 admitted + gates) content: application/json: - schema: { $ref: '#/components/schemas/CommonResultBoolean' } + schema: { $ref: '#/components/schemas/CommonResultProjectPublish' } /admin-api/project/review/page: get: @@ -146,6 +148,12 @@ components: code: { type: integer } data: { $ref: '#/components/schemas/ProjectRespVO' } msg: { type: string } + CommonResultProjectPublish: + type: object + properties: + code: { type: integer } + data: { $ref: '#/components/schemas/ProjectPublishRespVO' } + msg: { type: string } CommonResultProjectPage: type: object properties: @@ -178,6 +186,21 @@ components: properties: versionId: { type: integer, format: int64, description: 发布的版本 ID } launchZoneId: { type: integer, format: int64, description: 发布去向专区(launchZone ←G1) } + GateResultVO: + type: object + required: [key, pass] + properties: + key: { type: string, description: '门禁键(稳定标识,如 age;Phase C 增 compliance)' } + label: { type: string, description: 门禁中文标签(展示用) } + pass: { type: boolean, description: 是否通过 } + code: { type: string, description: '拦截时错误码字符串(pass=true 时为 null;复用对应模块错误码段,不新增)' } + message: { type: string, description: 拦截时提示文案(pass=true 时为 null) } + ProjectPublishRespVO: + type: object + required: [admitted, gates] + properties: + admitted: { type: boolean, description: '是否准入(全部门禁通过=true;任一拦截=false 且不改状态机)' } + gates: { type: array, items: { $ref: '#/components/schemas/GateResultVO' }, description: 发布前门禁逐项结果 } ReviewReqVO: type: object required: [gameId, versionId, decision] diff --git a/contracts/events.schema.json b/contracts/events.schema.json index e9762acb..e8c874f0 100644 --- a/contracts/events.schema.json +++ b/contracts/events.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://wanxiang.ai/contracts/events.schema.json", "title": "TelemetryEvents", - "description": "绘境AI 遥测事件契约 v1(契约 #5,owner=WS5)。前端批量上报 / SDK 上报 → /app-api/telemetry/events/batch → MQ 异步入库 → quality_score → feed 排序(数据回路闭环)。每事件带 schema_version;新增字段给默认值、老消费者忽略未知字段;不兼容变更用新事件名。", + "description": "造梦AI 遥测事件契约 v1(契约 #5,owner=WS5)。前端批量上报 / SDK 上报 → /app-api/telemetry/events/batch → MQ 异步入库 → qualityScore → feed 排序(数据回路闭环)。每事件带 schemaVersion;新增字段给默认值、老消费者忽略未知字段;不兼容变更用新事件名。【Wave3 GAP-4 评审记录】利用无生产数据 + MQ 未实现窗口,一次性把信封 envelope/user 由 snake_case 统一为 camelCase(与 telemetry.yaml 及前端 SDK 对齐),保持 additionalProperties:false;豁免本文件 README §2『不兼容变更用新事件名并行』规则(本次改的是信封字段命名而非事件名),已确认仓外无 snake_case 消费者(telemetry.yaml/sdk-interface.d.ts/game-studio 均为 camelCase)。", "type": "object", "required": ["envelope", "eventRegistry"], "additionalProperties": false, @@ -10,21 +10,21 @@ "envelope": { "description": "事件信封(所有事件共用)。批量上报时为 { batch: Envelope[] }。", "type": "object", - "required": ["event", "schema_version", "ts", "trace_id"], + "required": ["event", "schemaVersion", "ts", "traceId"], "additionalProperties": false, "properties": { "event": { "description": "事件名(见 eventRegistry 键)", "type": "string" }, - "schema_version": { "description": "事件 Schema 版本,如 v1 / v2", "type": "string", "pattern": "^v[0-9]+$" }, + "schemaVersion": { "description": "事件 Schema 版本,如 v1 / v2", "type": "string", "pattern": "^v[0-9]+$" }, "ts": { "description": "事件发生时间戳(毫秒)", "type": "integer" }, - "trace_id": { "description": "全链路追踪 ID,贯穿生成→编译→加载→运行→上报", "type": "string" }, - "session_id": { "description": "会话 ID(一次游戏流消费/一次创作会话)", "type": "string" }, + "traceId": { "description": "全链路追踪 ID,贯穿生成→编译→加载→运行→上报", "type": "string" }, + "sessionId": { "description": "会话 ID(一次游戏流消费/一次创作会话)", "type": "string" }, "user": { - "description": "用户标识。匿名玩家用 anon_id;登录后带 user_id。", + "description": "用户标识。匿名玩家用 anonId;登录后带 userId。", "type": "object", "additionalProperties": false, "properties": { - "user_id": { "type": "string" }, - "anon_id": { "description": "匿名 token 派生的稳定匿名 ID", "type": "string" } + "userId": { "type": "string" }, + "anonId": { "description": "匿名 token 派生的稳定匿名 ID", "type": "string" } } }, "context": { @@ -32,10 +32,10 @@ "type": "object", "additionalProperties": true, "properties": { - "game_id": { "type": "string" }, - "version_id": { "type": "string" }, + "gameId": { "type": "string" }, + "versionId": { "type": "string" }, "channel": { "description": "渠道归因(utm/channel 解析)", "type": "string" }, - "device_type": { "type": "string", "enum": ["mobile", "desktop", "miniapp"] } + "deviceType": { "type": "string", "enum": ["mobile", "desktop", "miniapp"] } } }, "props": { "description": "事件私有字段(结构见对应 eventRegistry 项)", "type": "object" } diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java index 1b6a6746..a0560643 100644 --- a/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java @@ -7,6 +7,7 @@ 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.RequestParam; /** @@ -37,4 +38,37 @@ public interface ProjectApi { @Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024") CommonResult getCreatorUserId(@RequestParam("gameId") Long gameId); + /** + * 按游戏 ID 反查当前生效版本 ID(GAP-3,供 studio 编排取 currentVersionId) + * + * 显式列、命中主键查询;游戏不存在或该字段未设置时 data 返回 null(null 安全,由调用方处理)。 + * + * @param gameId 游戏项目 ID(game_project.id) + * @return 当前生效版本 ID(CommonResult 包裹;不存在或无值为 null) + */ + @GetMapping(PREFIX + "/get-current-version-id") + @Operation(summary = "按游戏 ID 反查当前生效版本 ID(GAP-3)") + @Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024") + CommonResult getCurrentVersionId(@RequestParam("gameId") Long gameId); + + /** + * 创建项目(studio 写类 RPC,建草稿;显式传 creatorUserId 作 owner) + * + * RPC 无登录上下文,owner=显式入参 creatorUserId(由调用方在其受信边界确定),不裸暴露给前端、不从匿名上下文推断。 + * 内部以 title/templateId 构造 ProjectCreateReqVO 委托 ProjectService.createProject,返回新建 gameId。 + * + * @param creatorUserId 创作者用户 ID(项目 owner,调用方受信传入) + * @param title 标题 + * @param templateId 玩法模板 ID + * @return 新建项目 ID(CommonResult 包裹) + */ + @PostMapping(PREFIX + "/create") + @Operation(summary = "创建项目(studio 写类 RPC,owner=显式 creatorUserId)") + @Parameter(name = "creatorUserId", description = "创作者用户 ID(项目 owner)", required = true, example = "99") + @Parameter(name = "title", description = "标题", required = true, example = "我的放置小镇") + @Parameter(name = "templateId", description = "玩法模板 ID", required = true, example = "idle") + CommonResult createProject(@RequestParam("creatorUserId") Long creatorUserId, + @RequestParam("title") String title, + @RequestParam("templateId") String templateId); + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java index e29a1548..5c55576c 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java @@ -1,5 +1,6 @@ package cn.wanxiang.game.module.project.api; +import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectCreateReqVO; import cn.wanxiang.game.module.project.service.project.ProjectService; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import jakarta.annotation.Resource; @@ -31,4 +32,20 @@ public class ProjectApiImpl implements ProjectApi { return success(projectService.getCreatorUserId(gameId)); } + @Override + public CommonResult getCurrentVersionId(Long gameId) { + // GAP-3:按 game_project.id 返回 current_version_id;不存在或无值返回 null(由 studio 侧编排处理) + return success(projectService.getCurrentVersionId(gameId)); + } + + @Override + public CommonResult createProject(Long creatorUserId, String title, String templateId) { + // studio 写类 RPC:RPC 无登录上下文,owner=显式 creatorUserId(调用方受信传入),不裸暴露给前端 + // 以 title/templateId 构造既有 ProjectCreateReqVO,复用 ProjectService.createProject(建草稿 status=0) + ProjectCreateReqVO reqVO = new ProjectCreateReqVO(); + reqVO.setTitle(title); + reqVO.setTemplateId(templateId); + return success(projectService.createProject(reqVO, creatorUserId)); + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/AppProjectController.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/AppProjectController.java index 435ee661..0338bb55 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/AppProjectController.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/AppProjectController.java @@ -4,6 +4,7 @@ import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectCreateRe import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectDraftReqVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPageReqVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishReqVO; +import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishRespVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectRespVO; import cn.wanxiang.game.module.project.convert.project.ProjectConvert; import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO; @@ -73,13 +74,13 @@ public class AppProjectController { } @PostMapping("/{id}/publish") - @Operation(summary = "提交发布", description = "过发布前门禁后置 status=1 审核中") + @Operation(summary = "提交发布", description = "发布前门禁聚合:admitted=true 全过并进审核中;admitted=false 任一拦截、不改状态机") @Parameter(name = "id", description = "项目编号", required = true, example = "1024") - public CommonResult submitPublish(@PathVariable("id") Long id, - @Valid @RequestBody ProjectPublishReqVO publishReqVO) { + public CommonResult submitPublish(@PathVariable("id") Long id, + @Valid @RequestBody ProjectPublishReqVO publishReqVO) { Long userId = SecurityFrameworkUtils.getLoginUserId(); - projectService.submitPublish(id, publishReqVO, userId); - return success(true); + // 门禁结果整体返回(含逐项 gates),前端据 admitted 决定提示放行或逐条回显修复 + return success(projectService.submitPublish(id, publishReqVO, userId)); } } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/vo/GateResultVO.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/vo/GateResultVO.java new file mode 100644 index 00000000..6744dfc3 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/vo/GateResultVO.java @@ -0,0 +1,68 @@ +package cn.wanxiang.game.module.project.controller.app.project.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 发布前门禁单项结果 VO(锁风门 Gate 二态聚合的「条目」) + * + * 一个 gate = 一条发布前检查项(如适龄提示、合规扫描)。pass=false 表示该项拦截, + * 此时 code/message 给出可回显给创作者的错误码与文案;多条 gate 由 {@link ProjectPublishRespVO#getGates()} 聚合。 + * 注意:gate 是「业务可恢复」的拦截(返回 admitted=false 不抛异常),区别于归属/状态机这类「安全前置」(直接抛异常)。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 发布前门禁单项结果 VO") +@Data +public class GateResultVO { + + @Schema(description = "门禁键(稳定标识,前端据此定位)", requiredMode = Schema.RequiredMode.REQUIRED, example = "age") + private String key; + + @Schema(description = "门禁中文标签(展示用)", example = "适龄提示") + private String label; + + @Schema(description = "是否通过:true 通过 / false 拦截", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean pass; + + @Schema(description = "拦截时的错误码字符串(pass=true 时为 null;复用对应模块错误码段)", example = "1100000003") + private String code; + + @Schema(description = "拦截时的提示文案(pass=true 时为 null)", example = "发布前必须填写适龄提示") + private String message; + + /** + * 构造一条「通过」的门禁结果 + * + * @param key 门禁键 + * @param label 门禁标签 + * @return 通过态 GateResultVO(code/message 为 null) + */ + public static GateResultVO pass(String key, String label) { + GateResultVO vo = new GateResultVO(); + vo.setKey(key); + vo.setLabel(label); + vo.setPass(true); + return vo; + } + + /** + * 构造一条「拦截」的门禁结果 + * + * @param key 门禁键 + * @param label 门禁标签 + * @param code 错误码字符串(复用既有错误码,不新增登记) + * @param message 提示文案 + * @return 拦截态 GateResultVO + */ + public static GateResultVO block(String key, String label, String code, String message) { + GateResultVO vo = new GateResultVO(); + vo.setKey(key); + vo.setLabel(label); + vo.setPass(false); + vo.setCode(code); + vo.setMessage(message); + return vo; + } + +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/vo/ProjectPublishRespVO.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/vo/ProjectPublishRespVO.java new file mode 100644 index 00000000..649266c3 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/controller/app/project/vo/ProjectPublishRespVO.java @@ -0,0 +1,28 @@ +package cn.wanxiang.game.module.project.controller.app.project.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 提交发布响应 VO(发布前门禁聚合结果) + * + * publish 唯一化:所有发布前检查项(gates)汇聚为一次返回,由 admitted 表达整体放行/拦截。 + * - admitted=true:全部 gate 通过,状态机已置「审核中」; + * - admitted=false:至少一条 gate 拦截,状态机未改动(无副作用),创作者据 gates 修复后重提。 + * 注意:归属/草稿态等「安全前置」失败仍走异常(不进本 VO),仅业务可恢复的 gate 拦截以 admitted=false 返回。 + * + * @author 造梦AI + */ +@Schema(description = "产品端 - 提交发布 Response VO(发布前门禁聚合)") +@Data +public class ProjectPublishRespVO { + + @Schema(description = "是否准入(全部门禁通过=true;任一拦截=false 且不改状态机)", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean admitted; + + @Schema(description = "发布前门禁逐项结果(含通过与拦截项,供前端逐条回显)", requiredMode = Schema.RequiredMode.REQUIRED) + private List gates; + +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/project/ProjectMapper.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/project/ProjectMapper.java index b0c9d71b..a21441ed 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/project/ProjectMapper.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/project/ProjectMapper.java @@ -56,4 +56,18 @@ public interface ProjectMapper extends BaseMapperX { .eq(ProjectDO::getId, id)); // 命中主键 } + /** + * 按游戏 ID 查当前生效版本 ID(GAP-3,跨模块 Feign:studio 编排取 currentVersionId) + * + * 只取 current_version_id 单列、命中主键(id)——显式列、不裸 select*、不加载整行,最小化跨模块查询开销。 + * + * @param id 游戏项目 ID(主键) + * @return 仅含 current_version_id 的 DO;游戏不存在返回 null + */ + default ProjectDO selectCurrentVersionIdById(Long id) { + return selectOne(new LambdaQueryWrapperX() + .select(ProjectDO::getCurrentVersionId) // 显式只查版本所需列 + .eq(ProjectDO::getId, id)); // 命中主键 + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java index b93e437e..4072ae04 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java @@ -6,6 +6,7 @@ import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectCreateRe import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectDraftReqVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPageReqVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishReqVO; +import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishRespVO; import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO; import cn.iocoder.yudao.framework.common.pojo.PageResult; @@ -55,13 +56,16 @@ public interface ProjectService { void updateDraft(Long id, ProjectDraftReqVO reqVO, Long userId); /** - * 提交发布(仅 status=0 可提交;过发布前门禁后置 status=1 审核中) + * 提交发布(publish 唯一化:归属/草稿态为安全前置抛异常;发布前门禁聚合为 gates 不抛) + * + * 全部门禁通过 → 置 status=1 审核中,返回 admitted=true;任一门禁拦截 → 不改状态机、不写库,返回 admitted=false。 * * @param id 项目 ID * @param reqVO 版本 + 去向专区 * @param userId 当前登录创作者 ID + * @return 发布前门禁聚合结果(admitted + 逐项 gates) */ - void submitPublish(Long id, ProjectPublishReqVO reqVO, Long userId); + ProjectPublishRespVO submitPublish(Long id, ProjectPublishReqVO reqVO, Long userId); /** * 审核队列分页(admin 端,默认取审核中) @@ -89,4 +93,14 @@ public interface ProjectService { */ Long getCreatorUserId(Long gameId); + /** + * 按游戏 ID 反查当前生效版本 ID(GAP-3,供跨模块 Feign 调用,如 studio 编排取 currentVersionId) + * + * 只读、显式只查 current_version_id 单列、命中主键;游戏不存在或该字段未设置时返回 null(null 安全,由调用方处理)。 + * + * @param gameId 游戏项目 ID(game_project.id) + * @return 当前生效版本 ID;游戏不存在或无值返回 null + */ + Long getCurrentVersionId(Long gameId); + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java index 717619d1..403b1a51 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java @@ -6,6 +6,8 @@ import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectCreateRe import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectDraftReqVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPageReqVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishReqVO; +import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishRespVO; +import cn.wanxiang.game.module.project.controller.app.project.vo.GateResultVO; import cn.wanxiang.game.module.project.convert.project.ProjectConvert; import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO; import cn.wanxiang.game.module.project.dal.dataobject.review.ReviewRecordDO; @@ -20,6 +22,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import static cn.wanxiang.game.module.project.enums.ErrorCodeConstants.*; @@ -87,16 +91,39 @@ public class ProjectServiceImpl implements ProjectService { } @Override - public void submitPublish(Long id, ProjectPublishReqVO reqVO, Long userId) { + public ProjectPublishRespVO submitPublish(Long id, ProjectPublishReqVO reqVO, Long userId) { + // ===== 安全前置(失败抛异常,不进 gates)===== + // 归属/存在:非本人或不存在 → 抛 PROJECT_NOT_OWNER / PROJECT_NOT_EXISTS(数据边界,服务端强校验) ProjectDO project = validateProjectOwner(id, userId); - // 状态机:仅草稿可提交 + // 状态机:仅草稿可提交 → 非草稿抛 PROJECT_STATUS_NOT_DRAFT if (!ProjectStatusEnum.isDraft(project.getStatus())) { throw exception(PROJECT_STATUS_NOT_DRAFT); } - // 发布前检查门禁⑦:适龄提示必填 - if (!StringUtils.hasText(project.getAgeRating())) { - throw exception(PROJECT_PUBLISH_AGE_RATING_REQUIRED); + + // ===== 发布前门禁聚合(锁风门 Gate:收集结果不抛,由 admitted 表达整体放行/拦截)===== + List gates = new ArrayList<>(); + // 适龄门禁⑦:ageRating 非空=通过;空=拦截(复用既有错误码 PROJECT_PUBLISH_AGE_RATING_REQUIRED,不新增登记) + if (StringUtils.hasText(project.getAgeRating())) { + gates.add(GateResultVO.pass("age", "适龄提示")); + } else { + gates.add(GateResultVO.block("age", "适龄提示", + String.valueOf(PROJECT_PUBLISH_AGE_RATING_REQUIRED.getCode()), + PROJECT_PUBLISH_AGE_RATING_REQUIRED.getMsg())); } + // TODO Phase C 接入:经 ComplianceGateApi.evaluate(gameId, versionId) 注入 key=compliance 的 GateResultVO; + // 此刻 compliance-api 未建,本波 Phase A 不注入(gates 暂只有 age),此处不 import、不调用任何 compliance 类。 + + // ===== 准入裁决 ===== + ProjectPublishRespVO resp = new ProjectPublishRespVO(); + resp.setGates(gates); + // 任一门禁拦截 → 不改状态机、不写库(无副作用),返回 admitted=false 供创作者修复后重提 + boolean admitted = gates.stream().allMatch(GateResultVO::getPass); + resp.setAdmitted(admitted); + if (!admitted) { + return resp; + } + + // 全部门禁通过 → 置「审核中」并落版本/专区 // TODO 统一发布编排(compliance→runtime→feed,失败回滚 ←G6)属跨模块,MVP 阶段先只做状态机 + 门禁, // 待 compliance/runtime/feed 的 -api 就绪后在此补全编排与补偿逻辑。 ProjectDO update = new ProjectDO(); @@ -105,6 +132,7 @@ public class ProjectServiceImpl implements ProjectService { update.setLaunchZoneId(reqVO.getLaunchZoneId()); update.setStatus(ProjectStatusEnum.REVIEWING.getStatus()); // 进审核中 projectMapper.updateById(update); + return resp; } @Override @@ -144,6 +172,13 @@ public class ProjectServiceImpl implements ProjectService { return project == null ? null : project.getCreatorUserId(); } + @Override + public Long getCurrentVersionId(Long gameId) { + // GAP-3 只读:显式只查 current_version_id 单列、命中主键;游戏不存在或该字段未设置均返回 null(null 安全) + ProjectDO project = projectMapper.selectCurrentVersionIdById(gameId); + return project == null ? null : project.getCurrentVersionId(); + } + // ============================== 私有校验/流转辅助 ============================== /** diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java index 6a7b0b09..6350ce9f 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java @@ -4,6 +4,7 @@ import cn.wanxiang.game.module.project.controller.admin.project.vo.ReviewReqVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectCreateReqVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectDraftReqVO; import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishReqVO; +import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishRespVO; import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO; import cn.wanxiang.game.module.project.dal.dataobject.review.ReviewRecordDO; import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper; @@ -109,13 +110,19 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Test void testSubmitPublish_ageRatingRequired() { + // publish 唯一化:适龄门禁拦截不再抛异常,返回 admitted=false 且不改状态机 ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.DRAFT.getStatus()); - project.setAgeRating(""); // 适龄空 → 命中门禁⑦ + project.setAgeRating(""); // 适龄空 → 命中门禁⑦(age gate block) when(projectMapper.selectById(1L)).thenReturn(project); - ServiceException ex = assertThrows(ServiceException.class, - () -> projectService.submitPublish(1L, publishReq(), 99L)); - assertEquals(PROJECT_PUBLISH_AGE_RATING_REQUIRED.getCode(), ex.getCode()); + ProjectPublishRespVO r = projectService.submitPublish(1L, publishReq(), 99L); + + // 不准入 + age 门禁项 pass=false + assertFalse(r.getAdmitted()); + assertTrue(r.getGates().stream() + .anyMatch(g -> "age".equals(g.getKey()) && Boolean.FALSE.equals(g.getPass()))); + // 拦截不改状态机:绝不 updateById(带类型消歧) + verify(projectMapper, never()).updateById(any(ProjectDO.class)); } @Test @@ -124,14 +131,43 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { project.setAgeRating("all"); when(projectMapper.selectById(1L)).thenReturn(project); - projectService.submitPublish(1L, publishReq(), 99L); + ProjectPublishRespVO r = projectService.submitPublish(1L, publishReq(), 99L); + // 准入 + 全部门禁通过 + assertTrue(r.getAdmitted()); + assertTrue(r.getGates().stream().allMatch(g -> Boolean.TRUE.equals(g.getPass()))); + // 状态机置审核中 + 落版本 ArgumentCaptor captor = ArgumentCaptor.forClass(ProjectDO.class); verify(projectMapper).updateById(captor.capture()); assertEquals(ProjectStatusEnum.REVIEWING.getStatus(), captor.getValue().getStatus()); // 进审核中 assertEquals(2048L, captor.getValue().getCurrentVersionId()); } + @Test + void testSubmitPublish_notDraftRejected() { + // 安全前置:非草稿态仍抛 PROJECT_STATUS_NOT_DRAFT(不进 gates) + ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus()); + project.setAgeRating("all"); + when(projectMapper.selectById(1L)).thenReturn(project); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectService.submitPublish(1L, publishReq(), 99L)); + assertEquals(PROJECT_STATUS_NOT_DRAFT.getCode(), ex.getCode()); + verify(projectMapper, never()).updateById(any(ProjectDO.class)); // 带类型消歧 + } + + @Test + void testSubmitPublish_notOwner() { + // 安全前置:非归属仍抛 PROJECT_NOT_OWNER(不进 gates) + ProjectDO project = ownedProject(1L, 11L, ProjectStatusEnum.DRAFT.getStatus()); // 属于他人 + when(projectMapper.selectById(1L)).thenReturn(project); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectService.submitPublish(1L, publishReq(), 99L)); // 当前用户 99 + assertEquals(PROJECT_NOT_OWNER.getCode(), ex.getCode()); + verify(projectMapper, never()).updateById(any(ProjectDO.class)); // 带类型消歧 + } + // ============================== reviewProject 流转 ============================== @Test diff --git a/game-cloud/yudao-server/src/main/resources/db/migration/V6.0.0__create_game_ad.sql b/game-cloud/yudao-server/src/main/resources/db/migration/V6.0.0__create_game_ad.sql new file mode 100644 index 00000000..c97e1489 --- /dev/null +++ b/game-cloud/yudao-server/src/main/resources/db/migration/V6.0.0__create_game_ad.sql @@ -0,0 +1,77 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:ad(game-module-ad,Wave2 变现域「广告引擎」)| owner:WS5 +-- 文件:V6.0.0__create_game_ad.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移) +-- 内容:ad 模块核心表 —— 广告位配置 game_ad_slot(对齐契约#7)+ 广告收入台账 game_ad_revenue(ad↔trade seam 数据源) +-- 职责(架构 Doc B):联盟接入/广告位 AI 植入/曝光计费/eCPM/归因;不做结算提现(归 trade)。MVP provider 默认 mock。 +-- 脊柱(钱财闭环上游):SDK 拉广告位 → 有效曝光/激励完成计费上报(幂等) → mock eCPM 计收入 → 落 game_ad_revenue(settle_status=0) +-- → trade 经 AdRevenueApi.getUnsettledRevenue 拉取做 T+1 分账 → markSettled 回标 settle_status=1(seam 详见 ad.yaml x-feign-contracts) +-- 计费 vs 分析埋点:本模块台账=计费级(决定收入),区别于 telemetry(#5 ad_impression/ad_reward) 分析埋点(仅统计),二者并存口径不同 +-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id; +-- 金额一律「分」(BIGINT),禁浮点;状态机用 tinyint,非法流转由 Service 校验,DO 层不承载;中文列注释;关键查询建索引、禁裸 select*。 +-- 错误码段:ad = 1-111-***-*** +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 表:game_ad_slot —— 广告位配置表(字段严格对齐契约#7 ad-slot.schema.json) +-- 用途:admin CRUD 维护;SDK Plugin.Ad 拉 enabled=true 广告位渲染(/app-api/ad/slot/list-enabled) +-- Provider SPI:provider=mock 走桩(MVP 默认);csj 穿山甲 / gdt 优量汇 走审核闸门后由人填 provider_slot_id 注入 +-- 唯一键:slot_id 业务唯一(uk_slot),埋点 ad_impression.slot_id 与计费上报均引用此逻辑 ID +-- compliance(未成年人保护)拆为显式列 block_minor / max_per_session,不用 JSON 整块存(便于查询与计费限流) +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_ad_slot` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '广告位主键 ID', + `slot_id` VARCHAR(64) NOT NULL COMMENT '广告位逻辑 ID(平台内唯一,对齐契约#7 slotId;埋点/计费上报均引用)', + `type` VARCHAR(16) NOT NULL DEFAULT 'rewarded' COMMENT '广告形式(对齐#7 type):rewarded 激励视频/interstitial 插屏/banner 横幅', + `provider` VARCHAR(16) NOT NULL DEFAULT 'mock' COMMENT '联盟(对齐#7 provider,SPI AdProvider):csj 穿山甲/gdt 优量汇/mock 桩(MVP 默认)', + `placement` VARCHAR(16) NOT NULL DEFAULT 'game_end' COMMENT '触发场景(对齐#7 placement):game_end 本局结束/pause 暂停/feed 游戏流间隙', + `provider_slot_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联盟侧真实广告位 ID(对齐#7 providerSlotId;provider!=mock 时由人提供——审核闸门后注入;不下发前端明文)', + `ecpm_floor` BIGINT NOT NULL DEFAULT 0 COMMENT 'eCPM 底价(单位:分,对齐#7 ecpmFloor);低于此不展示;mock 计费亦用此算单次收入', + `enabled` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否启用(对齐#7 enabled):0停用 1启用;SDK 只拉启用项', + `block_minor` BIT(1) NOT NULL DEFAULT b'1' COMMENT '合规:识别为未成年则不展示/不计费(对齐#7 compliance.blockMinor,默认 1=拦截)', + `max_per_session` INT NOT NULL DEFAULT 0 COMMENT '合规:单会话最大展示次数(对齐#7 compliance.maxPerSession;0=不限;计费上报按此限流)', + `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`), + UNIQUE KEY `uk_slot` (`slot_id`, `deleted`, `tenant_id`) COMMENT '广告位逻辑 ID 业务唯一键(含 deleted/tenant_id 适配 Yudao 逻辑删除+多租户,避免软删后无法复用 slot_id)', + KEY `idx_enabled` (`enabled`, `placement`) COMMENT 'SDK 按启用+场景拉广告位(list-enabled)', + KEY `idx_provider` (`provider`, `type`) COMMENT '管理端按联盟+形式分页/筛选' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '广告位配置表(对齐契约#7;admin CRUD + SDK 拉取)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_ad_revenue —— 广告收入台账(有效曝光/激励计费产出;ad↔trade seam 的数据源) +-- 用途:计费上报(impression/reward)按 mock eCPM 算收入落账;trade 按 settle_status=0 拉取做 T+1 分账 +-- 收入归因:creator_user_id = 由 game_id 反查 project.game_project.creator_user_id(收入归到游戏作者,trade 按此聚合分账) +-- 幂等核心:uk_trace=(trace_id,event_type),同 (trace_id,event_type) 重复上报只计一条(计费幂等);同一 trace_id 的 impression 与 reward 各落一条、互不覆盖(防 reward 被 impression 静默吞掉=丢账) +-- 收入口径(避免同一次展示重复计收入):type=rewarded 收入计在 reward(impression 若上报则 revenue_amount=0);type=interstitial/banner 收入计在 impression。event_type 仅区分计费事件,不对同一次展示重复计收入 +-- 结算状态机 settle_status:0未结算 → 1已结算(trade 分账入账成功后经 AdRevenueApi.markSettled 回标,幂等;非法回退由 Service 拒绝) +-- 扫描入口:trade 的 getUnsettledRevenue(statDate) 按 (settle_status=0, stat_date) 命中 idx_settle_date 扫描,禁裸 select* +-- 金额单位:ecpm / revenue_amount 一律「分」(BIGINT),禁浮点 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_ad_revenue` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '广告收入台账记录 ID(= trade 侧 game_trade_income.source_ref 对账锚点)', + `slot_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '广告位逻辑 ID(= game_ad_slot.slot_id)', + `game_id` BIGINT NOT NULL COMMENT '产生收入的游戏 ID(project.game_project.id)', + `creator_user_id` BIGINT NOT NULL COMMENT '收入归因到的创作者用户 ID(由 game_id 反查 game_project.creator_user_id;trade 按此聚合分账)', + `event_type` TINYINT NOT NULL DEFAULT 1 COMMENT '计费事件类型:1 impression 有效曝光 / 2 reward 激励完成', + `provider` VARCHAR(16) NOT NULL DEFAULT 'mock' COMMENT '产生收入的联盟:csj/gdt/mock(与广告位 provider 一致)', + `ecpm` BIGINT NOT NULL DEFAULT 0 COMMENT 'eCPM(单位:分;mock 取广告位 ecpm_floor)', + `revenue_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '本条广告收入(单位:分,BIGINT;金额一律用分禁浮点;mock=ecpm/1000 向下取整)', + `trace_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '全链路追踪 ID(与 event_type 组成计费幂等键 uk_trace);透传到 trade.trace_id 供端到端对账', + `settle_status` TINYINT NOT NULL DEFAULT 0 COMMENT '结算状态机:0未结算 1已结算(trade 分账后经 markSettled 回标 1;非法回退由 Service 拒绝)', + `stat_date` DATE NOT NULL COMMENT '归集日(yyyy-MM-dd,按日归集供 trade 按日扫未结算做 T+1 分账)', + `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 '更新者(trade 回标时为结算 job)', + `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_trace` (`trace_id`, `event_type`, `tenant_id`) COMMENT '计费幂等键:(trace_id+event_type) 唯一——同 (trace_id,event_type) 重复上报只落一条(计费幂等);同一 trace_id 的 impression 与 reward 各计一条、不互相覆盖(防 reward 被 impression 静默吞掉)', + KEY `idx_settle_date` (`settle_status`, `stat_date`) COMMENT 'trade 扫未结算做 T+1 分账(getUnsettledRevenue 按 settle_status=0 + stat_date 扫描)', + KEY `idx_creator_date` (`creator_user_id`, `stat_date`) COMMENT '按创作者+归集日聚合分账/对账', + KEY `idx_game` (`game_id`, `stat_date`) COMMENT '按游戏查收入(归因/计费对账)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '广告收入台账表(计费产出;ad↔trade seam 数据源,settle_status 供 trade 分账)'; diff --git a/game-cloud/yudao-server/src/main/resources/db/migration/V7.0.0__create_game_trade.sql b/game-cloud/yudao-server/src/main/resources/db/migration/V7.0.0__create_game_trade.sql new file mode 100644 index 00000000..4c295f19 --- /dev/null +++ b/game-cloud/yudao-server/src/main/resources/db/migration/V7.0.0__create_game_trade.sql @@ -0,0 +1,116 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:trade(game-module-trade,Wave2 变现域「分账/结算/提现」)| owner:WS5 +-- 文件:V7.0.0__create_game_trade.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移) +-- 内容:trade 模块核心表 —— 收益账户 game_trade_account + 收益流水 game_trade_income + 提现申请 game_trade_withdraw +-- 职责(架构 Doc B):多源收入分账/归集/结算/对账;钱包打款/提现门槛与打款执行属 trade(不属 pay);不做支付收单(归 pay)。 +-- MVP 钱财闭环(决策 D3):广告收入 → trade 分账(创作者 80%) → 创作者收益账户 → 提现(满 5 元)。会员订阅/支付收单=P1,本轮不做。 +-- 上游 seam:消费 ad 模块 AdRevenueApi.getUnsettledRevenue/markSettled(详见 ad.yaml x-feign-contracts),T+1 SettlementJob 据此分账。 +-- +-- 【配置化(决策 D3,全配置走 Nacos/DB,严禁硬编码)—— 本迁移不建死值,运行时读 Nacos】: +-- - trade.creator-share = 0.80 (MVP 全档统一 80% 分账比例;分层结构 creator_share_tier 保留、值后期填,见下方占位说明) +-- - trade.withdraw-min = 500 (单位:分,满 5 元可提现门槛) +-- 分账规则不建表(走 Nacos 配置);分层占位说明(later):若引入分层分账,可加 game_trade_share_rule +-- (tier 等级/min_income 门槛/share_rate 比例),按创作者等级取不同 share_rate;MVP 不建,统一读 trade.creator-share。 +-- 落账定格:game_trade_income.share_rate 落账时定格当时的 Nacos 比例快照,不随后续配置变动重算(保证历史流水可对账、金额不漂移)。 +-- +-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id; +-- 金额一律「分」(BIGINT) 禁浮点,比例用 DECIMAL;资金写入有幂等键(biz_no/source_ref + uk);状态机用 tinyint,非法流转 Service 拒绝; +-- 账户表 = 流水的物化汇总,余额变更必有对应流水/单据;中文列注释;关键查询建索引、禁裸 select*。 +-- 错误码段:trade = 1-106-***-*** +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 表:game_trade_account —— 创作者收益账户(物化汇总;余额变更必有对应流水/提现单) +-- 不变式:total_income = Σ game_trade_income.net_amount(本人); +-- balance + frozen + total_withdraw = total_income(余额 + 冻结中 + 已提现 = 累计收益) +-- 写入点:① 分账入账(SettlementJob)→ total_income += net、balance += net; +-- ② 提现申请 → balance -= amount、frozen += amount(冻结); +-- ③ 打款成功 → frozen -= amount、total_withdraw += amount; +-- ④ 驳回/打款失败 → frozen -= amount、balance += amount(退回)。 +-- 并发:余额增减用行级原子更新(UPDATE ... SET balance = balance ± ? WHERE user_id=? AND balance >= ?),扣减带余额充足条件防超扣。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_trade_account` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '收益账户 ID', + `user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(DataPermission:创作者只见自己账户)', + `balance` BIGINT NOT NULL DEFAULT 0 COMMENT '可提现余额(单位:分,BIGINT 禁浮点)', + `frozen` BIGINT NOT NULL DEFAULT 0 COMMENT '冻结中(提现处理中占用,单位:分;提现申请时从 balance 转入)', + `total_income` BIGINT NOT NULL DEFAULT 0 COMMENT '累计收益(历史分账入账总额,单位:分;= Σ 本人收益流水 net_amount)', + `total_withdraw` BIGINT NOT NULL DEFAULT 0 COMMENT '累计已提现(已打款成功总额,单位:分)', + `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`), + UNIQUE KEY `uk_user` (`user_id`, `tenant_id`) COMMENT '一个创作者一个收益账户(幂等初始化,防并发重复建账户)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作者收益账户表(流水物化汇总,余额变更必有流水)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_trade_income —— 收益流水(分账后入账,每笔来源收入一条;账户余额的明细来源) +-- 分账:net_amount = gross_amount × share_rate(创作者实得,落账时按分计算定格);平台分成 = gross - net(不单独存,报表算) +-- 幂等核心:uk_source(source, source_ref) 防同一笔来源收入重复分账—— +-- 广告:source=1(ad)、source_ref=game_ad_revenue.id;打赏:source=2(tip)、source_ref=打赏单 id。 +-- SettlementJob 重跑/补偿时,命中 uk_source 即跳过该笔,保证不重复入账(不重复发钱)。 +-- 对账锚点:source_ref + trace_id 与 ad 侧 game_ad_revenue.id + trace_id 对齐,端到端可对账。 +-- 注:本流水即「income_settled 已落账」事实;契约#5 income_settled 事件(props:amount/source)为分析埋点镜像,不替代本表。 +-- 金额单位:gross_amount / net_amount 一律「分」(BIGINT) 禁浮点;share_rate 用 DECIMAL(5,4)(如 0.8000)。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_trade_income` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '收益流水 ID', + `user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(收入归属,DataPermission:只见自己)', + `source` TINYINT NOT NULL COMMENT '收入来源:1 ad 广告 / 2 tip 打赏(MVP 仅 ad;tip 留位)', + `source_ref` VARCHAR(64) NOT NULL COMMENT '来源单号(对账锚点):广告=game_ad_revenue.id;打赏=打赏单 id。与 source 组成幂等唯一键', + `gross_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '原始收入(分账前,单位:分)', + `share_rate` DECIMAL(5,4) NOT NULL DEFAULT 0.0000 COMMENT '分账比例(如 0.8000=创作者得 80%;落账时定格 Nacos trade.creator-share 快照,不随配置变动重算)', + `net_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '创作者实得 = gross_amount × share_rate(单位:分,落账定格;累加入账户 balance/total_income)', + `settle_date` DATE NOT NULL COMMENT 'T+1 结算日(yyyy-MM-dd;= 上游 game_ad_revenue.stat_date)', + `trace_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '全链路 traceId(广告来源透传自 game_ad_revenue.trace_id,供端到端对账)', + `creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列;分账 job 为 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`), + UNIQUE KEY `uk_source` (`source`, `source_ref`, `tenant_id`) COMMENT '幂等核心:同一笔来源收入(source+source_ref)只分账入账一次,防重复发钱', + KEY `idx_user_settle` (`user_id`, `settle_date`) COMMENT '我的收益流水分页(创作者+结算日)', + KEY `idx_settle_date` (`settle_date`) COMMENT '平台营收报表按日聚合 gross/net' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '收益流水表(分账后入账;账户余额明细来源,uk 防重复分账)'; + +-- ----------------------------------------------------------------------------- +-- 表:game_trade_withdraw —— 提现申请(提现门槛/冻结/审核/打款执行,属 trade 不属 pay) +-- 状态机 status:0待审核 →(1审核通过打款中 / 3驳回) ;1 →(2已打款 / 4打款失败) +-- 合法流转(非法流转由 WithdrawService 拒绝,DO 层不承载校验): +-- 0 → 1(审核通过) :管理端审核通过 +-- 0 → 3(审核驳回) :冻结金额 frozen 退回 balance(资金原路退回,必填 reject_reason) +-- 1 → 2(打款成功,终态) :frozen 扣减、total_withdraw 累加;MVP mock 渠道审核通过即置 2 +-- 1 → 4(打款失败,终态) :frozen 退回 balance,可重新发起新单 +-- 终态:2/3/4 不可再变;非法如 0→2(未审核直接打款)、2→*(已打款再变)一律拒绝。 +-- 幂等核心:uk_biz_no(biz_no) 防重复提现申请——同一 biz_no 重复提交返回已有申请,不重复冻结余额。 +-- 资金安全:申请时原子校验 amount ≥ Nacos trade.withdraw-min 且 ≤ balance,扣 balance 加 frozen;金额单位「分」。 +-- ----------------------------------------------------------------------------- +CREATE TABLE `game_trade_withdraw` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '提现申请 ID', + `user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(DataPermission:只见自己提现)', + `amount` BIGINT NOT NULL COMMENT '提现金额(单位:分,BIGINT 禁浮点;申请时校验 ≥Nacos trade.withdraw-min 且 ≤可提现余额)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态机:0待审核 1审核通过打款中 2已打款 3驳回 4打款失败(合法流转见表头注释,非法流转 Service 拒绝)', + `biz_no` VARCHAR(64) NOT NULL COMMENT '提现业务单号 = 幂等键(前端生成唯一;重复提交同一 biz_no 不重复冻结)', + `channel` VARCHAR(16) NOT NULL DEFAULT 'mock' COMMENT '打款渠道:MVP=mock 桩;真实 later 接 pay 渠道异步回调', + `apply_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间', + `audit_time` DATETIME NULL COMMENT '审核时间(审核后写入)', + `audit_user_id` BIGINT NULL COMMENT '审核人用户 ID(管理端 RBAC)', + `pay_time` DATETIME NULL COMMENT '打款时间(打款成功 status=2 时写入)', + `reject_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '驳回理由(status=3 时必填,回填创作者)', + `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注(申请/审核备注)', + `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`), + UNIQUE KEY `uk_biz_no` (`biz_no`, `tenant_id`) COMMENT '提现幂等键:同一 biz_no 只受理一次(防重复提现/重复冻结)', + KEY `idx_user_status` (`user_id`, `status`) COMMENT '我的提现记录分页(创作者+状态)', + KEY `idx_status_apply` (`status`, `apply_time`) COMMENT '管理端提现审核队列(按状态+申请时间)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '提现申请表(提现门槛/冻结/审核/打款,属 trade)';