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:
parent
5be2251498
commit
096020fa0a
@ -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 当前仓库(文档仓)目录结构
|
||||
|
||||
```
|
||||
|
||||
@ -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` 错误码常量类登记,禁止重叠。
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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)); // 命中主键
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
// ============================== 私有校验/流转辅助 ==============================
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 分账)';
|
||||
@ -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)';
|
||||
Loading…
x
Reference in New Issue
Block a user