feat(wave3-phaseA): project 侧跨主线收口(publish 唯一化+ProjectApi seam+GAP-4+治理+V6V7 G0)

Phase A 串行收口(主 agent 独占改黄金模块,11 单测绿),为 Phase B 并行建模块铺底:
- R1 publish 唯一化:submitPublish void→ProjectPublishRespVO{admitted,gates};门禁收集不抛、
  block→admitted=false 不改状态机;compliance gate 留 Phase C 接入 seam(TODO,不 import)
- R2 ProjectApi seam 单点定稿:+getCurrentVersionId(GAP-3)/+createProject(写类RPC 显式 creatorUserId)
- GAP-4:events.schema.json 信封 envelope/user 由 snake→camelCase(与 telemetry/前端 SDK 对齐,破坏性窗口收敛)
- R4 治理:登记 studio=112/Flyway V8(README §4/§1/§2 + CLAUDE.md;与前端仓 game-studio 区分)
- R6 G0:V6/V7 集中复制到 yudao-server/db/migration(diff-identical)
- project.yaml publish 响应同步结构化;D2 锚点 reviewProject/game_review_record 未动

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-09 01:37:06 +00:00
parent 5be2251498
commit 096020fa0a
15 changed files with 504 additions and 36 deletions

View File

@ -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 当前仓库(文档仓)目录结构
```

View File

@ -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` 错误码常量类登记,禁止重叠。

View File

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

View File

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

View File

@ -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<Long> 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<Long> 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<Long> createProject(@RequestParam("creatorUserId") Long creatorUserId,
@RequestParam("title") String title,
@RequestParam("templateId") String templateId);
}

View File

@ -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<Long> getCurrentVersionId(Long gameId) {
// GAP-3:按 game_project.id 返回 current_version_id;不存在或无值返回 null(由 studio 侧编排处理)
return success(projectService.getCurrentVersionId(gameId));
}
@Override
public CommonResult<Long> 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));
}
}

View File

@ -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<Boolean> submitPublish(@PathVariable("id") Long id,
@Valid @RequestBody ProjectPublishReqVO publishReqVO) {
public CommonResult<ProjectPublishRespVO> 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));
}
}

View File

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

View File

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

View File

@ -56,4 +56,18 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
.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<ProjectDO>()
.select(ProjectDO::getCurrentVersionId) // 显式只查版本所需列
.eq(ProjectDO::getId, id)); // 命中主键
}
}

View File

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

View File

@ -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<GateResultVO> 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();
}
// ============================== 私有校验/流转辅助 ==============================
/**

View File

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

View File

@ -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 分账)';

View File

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