6aa2f97410
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
产品口径:「子代所有的都算父代的」,并要能溯源「多少是子代给的、多少是自己的」。本期**只做计算**,
不含资金/分成结算。
- 递归定义:`inherited(W) = Σ_{c∈直接子代} total(c)`,`total(W) = own(W) + inherited(W)`;每个后代在它的每个
祖先里只计一次。
- 接口:`GET /api/game-distribution/games/{game_id}/contribution`(Bearer + `no-store`)——**仅该作品作者**:
未登录/失效 401(中间件)、非作者 403(复用既有 owner-mismatch → FORBIDDEN,不新增第二套鉴权)、
作品不存在或根自身已软删 404。响应:`{ gameId, own, inherited, total, byGeneration[], directChildren[],
nodeCount, truncated, truncatedReason }`;指标结构 `ContributionTotals{playCount}` 可扩展(将来加点赞/收藏/收入)。
- 不变量(测试钉住):`inherited == Σ byGeneration.total == Σ directChildren.total`、`total == own + inherited`、
`nodeCount == Σ byGeneration.gameCount`;`byGeneration` 只含后代(绝对代际,升序)。
- 上限与截断:节点 500 / 深度 32;超限如实标 `truncated: true` + `truncatedReason ∈ {node_limit, depth_limit}`,
已计入部分仍自洽(与族谱 `truncated` 同约定,不静默给半个数);遍历只用血缘表既有索引,**不新增表/索引**。
- 契约:shared-contracts 4 个 DTO + TS 镜像 + parity(+4 组 → 66 组);7 个生成绑定(含 procedure)。
- 文档:技术方案新增 `§3.11 贡献归集与权益归因`(定义/归因分解/接口/上限与截断/鉴权口径与理由)+ §3.4 路由行 +
§5.4 实施证据;**任务 B**:反洗稿(相似度校验)从「待拍板」改为「**产品已决定搁置(2026-10-06)**」并从待拍板
清单移除,结算口径统一为「本期只做贡献归集与归因计算」。
- 测试:module-game-distribution **131 passed**(contribution 12 条,含 A→B→C 三层链路不变量);spacetime-module
**304 passed / 1 ignored**(+3 结构断言);spacetime-client 43 passed;api-server game_distribution **107 passed**(+3)。
门禁:wasm build 0;`cargo check --all-targets` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`
0(98 表);DTO parity 0(66 组 / 17 构建器 / 15 手拼类型);`check:project-bundle-policy-parity` OK;
`check:encoding` 0(5543 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。
注:本块未提交 `scripts/*.mjs`(`check-game-distribution-lineage-e2e.mjs` / `-theme-e2e.mjs` /
`capture-game-lineage-visual.mjs`)——属其它 owner,工作区保留未提交。
856 lines
32 KiB
TypeScript
856 lines
32 KiB
TypeScript
export const GAME_DISTRIBUTION_CATEGORIES = [
|
||
'休闲',
|
||
'益智',
|
||
'动作',
|
||
'冒险',
|
||
'模拟',
|
||
'策略',
|
||
'其他',
|
||
] as const;
|
||
|
||
export type GameDistributionCategory =
|
||
(typeof GAME_DISTRIBUTION_CATEGORIES)[number];
|
||
|
||
export type GameDistributionPublishMetadataSuggestionRequest = {
|
||
name: string;
|
||
goal?: string | null;
|
||
/** 脱敏且有界的项目上下文摘要。 */
|
||
context?: string | null;
|
||
};
|
||
|
||
export type GameDistributionPublishMetadataSuggestion = {
|
||
summary: string;
|
||
category: GameDistributionCategory;
|
||
};
|
||
|
||
export type GameDistributionDeviceSupport = {
|
||
desktop: boolean;
|
||
mobile: boolean;
|
||
touch: boolean;
|
||
};
|
||
|
||
export type GameDistributionInputMode = 'keyboard' | 'mouse' | 'touch';
|
||
export type GameDistributionOrientation =
|
||
| 'landscape'
|
||
| 'portrait'
|
||
| 'responsive';
|
||
|
||
export type GameDistributionAuthor = {
|
||
id: string;
|
||
name: string;
|
||
avatarUrl?: string | null;
|
||
};
|
||
|
||
export type GameDistributionReview = {
|
||
/** 管理员隐藏后仍允许本人读取和编辑,不参与公共统计。 */
|
||
isHidden: boolean;
|
||
id: string;
|
||
gameId: string;
|
||
author: GameDistributionAuthor;
|
||
score: number;
|
||
comment: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
};
|
||
|
||
export type GameDistributionRatingSummary = {
|
||
averageScore: number | null;
|
||
ratingCount: number;
|
||
};
|
||
|
||
export type GameDistributionReviewsResponse = {
|
||
reviews: GameDistributionReview[];
|
||
page: number;
|
||
pageSize: number;
|
||
total: number;
|
||
totalPages: number;
|
||
ratingSummary: GameDistributionRatingSummary;
|
||
};
|
||
|
||
export type GameDistributionMyReviewResponse = {
|
||
review: GameDistributionReview | null;
|
||
};
|
||
|
||
export type GameDistributionSaveReviewRequest = {
|
||
score: number;
|
||
comment?: string;
|
||
};
|
||
|
||
export type GameDistributionSaveReviewResponse = {
|
||
review: GameDistributionReview;
|
||
ratingSummary: GameDistributionRatingSummary;
|
||
};
|
||
|
||
export type GameDistributionVersionStatus =
|
||
| 'awaiting_upload'
|
||
| 'uploaded'
|
||
| 'validating'
|
||
| 'pending_review'
|
||
| 'published'
|
||
| 'upload_failed'
|
||
| 'validation_failed'
|
||
| 'rejected'
|
||
| 'cancelled'
|
||
| 'revoked';
|
||
|
||
export type GameDistributionGameVisibility =
|
||
| 'unpublished'
|
||
| 'published'
|
||
| 'suspended';
|
||
|
||
export type GameDistributionVersionSummary = {
|
||
id: string;
|
||
version: string;
|
||
/**
|
||
* 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
|
||
* 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。
|
||
*
|
||
* 买断制作品在未购买时不下发公开入口,这一项为 `null`;游玩入口改由播放会话接口签发。
|
||
*/
|
||
entryUrl: string | null;
|
||
sha256: string;
|
||
publishedAt: string;
|
||
controls: string[];
|
||
/**
|
||
* 本版相对改编来源作品的核心改动说明(衍生作品必有;母版为 `null`)。
|
||
*
|
||
* 版本级字段:详情页与族谱溯源逐版渲染「每一代的差异」。公开投影里它不含任何私有字段
|
||
* (没有对象键 / 素材 id),因此匿名可读;母版发 `null` 而不是省略该键。
|
||
*/
|
||
changeSummary?: string | null;
|
||
};
|
||
|
||
/**
|
||
* 共创授权三态:只允许单向提升(forbidden → nonCommercial → full),不可降级。
|
||
*/
|
||
export type GameDistributionForkAuthorization =
|
||
| 'forbidden'
|
||
| 'nonCommercial'
|
||
| 'full';
|
||
|
||
/** 授权阶梯顺序;`canUpgradeGameForkAuthorization` 按这个顺序判定可提升档位。 */
|
||
export const GAME_DISTRIBUTION_FORK_AUTHORIZATIONS: readonly GameDistributionForkAuthorization[] =
|
||
['forbidden', 'nonCommercial', 'full'];
|
||
|
||
/** 旧数据、字段缺省或未知取值时的兜底档位:保守拒绝共创。 */
|
||
export const GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION: GameDistributionForkAuthorization =
|
||
'forbidden';
|
||
|
||
export const GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS: Record<
|
||
GameDistributionForkAuthorization,
|
||
string
|
||
> = {
|
||
forbidden: '禁止共创',
|
||
nonCommercial: '允许非商用共创',
|
||
full: '允许全开放共创',
|
||
};
|
||
|
||
/** 空间受限场景(卡片行内选择等)用的短标签。 */
|
||
export const GAME_DISTRIBUTION_FORK_AUTHORIZATION_SHORT_LABELS: Record<
|
||
GameDistributionForkAuthorization,
|
||
string
|
||
> = {
|
||
forbidden: '禁止',
|
||
nonCommercial: '非商用',
|
||
full: '全开放',
|
||
};
|
||
|
||
/** 把任意原始值收敛到合法档位;缺省与未知一律按 `forbidden` 兜底。 */
|
||
export function resolveGameForkAuthorization(
|
||
value: string | null | undefined,
|
||
): GameDistributionForkAuthorization {
|
||
return GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.includes(
|
||
value as GameDistributionForkAuthorization,
|
||
)
|
||
? (value as GameDistributionForkAuthorization)
|
||
: GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION;
|
||
}
|
||
|
||
/** 授权阶梯比较:只有严格高于当前档位的目标才允许提交。 */
|
||
export function canUpgradeGameForkAuthorization(
|
||
current: GameDistributionForkAuthorization,
|
||
target: GameDistributionForkAuthorization,
|
||
) {
|
||
return (
|
||
GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.indexOf(target) >
|
||
GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.indexOf(current)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 作品血缘摘要:代际、父作品与根作品。
|
||
* 根作品或旧数据为 null/缺省;父作品下架后仍按快照回传标题与作者名,用于溯源署名。
|
||
* 父/根作品被**软删除**(`game_distribution_game.deleted_at` 非空)时不再对外输出标题,
|
||
* `rootTitle` / `parentTitle` 为 null,`parentAuthorName` 一并缺省,展示层降级为
|
||
* 「原作品已不可用」;ID 与代际照常返回。
|
||
* `parentAuthorName` 在账号信息不可得时可能缺省或为 null,展示层按「无署名」处理。
|
||
*/
|
||
export type GameDistributionLineage = {
|
||
generation: number;
|
||
rootGameId: string;
|
||
rootTitle: string | null;
|
||
parentGameId: string;
|
||
parentTitle: string | null;
|
||
parentAuthorName?: string | null;
|
||
};
|
||
|
||
/**
|
||
* 族谱树上的单个作品节点。
|
||
*
|
||
* 只有公开且未软删除的作品会成为节点(锚点不可读时接口直接 404),因此 `title` 必然存在;
|
||
* `authorName` 为 null 表示作者信息不可得。`parentGameId` 指向的作品可能不在树里
|
||
* (父作品已删/未公开),展示层据此把该节点标注为「原作品不可用」,不猜测父作品内容。
|
||
* 节点只带作品级公开信息,不含**素材键 / 内部 id**(例如 `coverAssetId`);`coverObjectKey`
|
||
* 是公开投影的一部分,与公开目录 / 详情同源同口径,无封面时为 null。
|
||
*/
|
||
export type GameDistributionLineageNode = {
|
||
gameId: string;
|
||
title: string;
|
||
authorName?: string | null;
|
||
/** 代际;母版为 0。 */
|
||
generation: number;
|
||
/** 父作品 ID;母版为 null。 */
|
||
parentGameId?: string | null;
|
||
playCount: number;
|
||
status: GameDistributionGameVisibility;
|
||
/**
|
||
* 该节点作品当前生效的封面对象键(与公开目录 / 详情同源,都来自游戏行
|
||
* `cover_object_key`);无封面时为 null。只发对象键,不带 `coverAssetId` 等私有 id。
|
||
*/
|
||
coverObjectKey?: string | null;
|
||
};
|
||
|
||
/**
|
||
* 族谱读接口响应:`nodes` 按代际升序、同代按创建时间升序稳定排序,含根(若根可见);
|
||
* 超过节点上限时截断尾部并置 `truncated`,不静默丢弃。
|
||
*/
|
||
export type GameDistributionLineageResponse = {
|
||
/** 请求作品所属的根作品 ID;根不可用时仍返回,展示层据此标注树顶不可用。 */
|
||
rootGameId: string;
|
||
/** 根节点在可见集合中的投影;根已删或未公开时为 null。 */
|
||
root?: GameDistributionLineageNode | null;
|
||
nodes: GameDistributionLineageNode[];
|
||
truncated: boolean;
|
||
};
|
||
|
||
/** 直接衍生作品(「被改编」)列表响应;只含对外可见的直接子代。 */
|
||
export type GameDistributionDerivedResponse = {
|
||
gameId: string;
|
||
nodes: GameDistributionLineageNode[];
|
||
truncated: boolean;
|
||
};
|
||
|
||
/**
|
||
* 归集的指标集合(当前只有游玩数)。
|
||
*
|
||
* 单独成结构体而不是把字段摊进响应里:将来加点赞 / 收藏 / 收入只需加字段,
|
||
* 递归定义(`inherited(W) = Σ 直接子代 total`、`total = own + inherited`)与投影形状都不动。
|
||
*/
|
||
export type GameDistributionContributionTotals = {
|
||
playCount: number;
|
||
};
|
||
|
||
/**
|
||
* 按代际分解的一条:**只含后代**(不含根自身),`generation` 为绝对代际(与族谱一致)。
|
||
*
|
||
* 语义:这一条是祖先 `inherited` 里**由这一代自己产生**的那一份,`total` 即该代贡献;
|
||
* `inherited` 恒为 `0`——该代从更深代际继承到的量已计入更深代际那一条,重复计入会让
|
||
* 「各代之和」大于祖先的 `inherited`(同一个后代会在多层分解里被重复计数)。
|
||
*/
|
||
export type GameDistributionContributionGeneration = {
|
||
generation: number;
|
||
/** 这一代计入分解的后代节点数。 */
|
||
gameCount: number;
|
||
own: GameDistributionContributionTotals;
|
||
inherited: GameDistributionContributionTotals;
|
||
total: GameDistributionContributionTotals;
|
||
};
|
||
|
||
/** 直接子代明细的一条:`total` 是该直接子代**含自己整棵子树**的值。 */
|
||
export type GameDistributionContributionChild = {
|
||
gameId: string;
|
||
generation: number;
|
||
own: GameDistributionContributionTotals;
|
||
inherited: GameDistributionContributionTotals;
|
||
total: GameDistributionContributionTotals;
|
||
};
|
||
|
||
/**
|
||
* 作者视角的贡献归集与归因响应(`GET /api/game-distribution/games/{gameId}/contribution`)。
|
||
*
|
||
* 不变量(在**截断后的子树**上依然成立):
|
||
* - `inherited == Σ byGeneration.total == Σ directChildren.total`;
|
||
* - `total == own + inherited`;
|
||
* - `nodeCount == Σ byGeneration.gameCount`(= 参与计算的**后代**节点数,不含根)。
|
||
*
|
||
* `truncated` 为真时 `truncatedReason` 取 `node_limit` / `depth_limit`,未截断为 `null`
|
||
* (与族谱接口 `truncated` 同约定:超限如实标注,不静默给半个数)。
|
||
* **本期只做计算**:响应里没有资金 / 分成 / 结算字段。
|
||
*/
|
||
export type GameDistributionContributionResponse = {
|
||
gameId: string;
|
||
own: GameDistributionContributionTotals;
|
||
inherited: GameDistributionContributionTotals;
|
||
total: GameDistributionContributionTotals;
|
||
byGeneration: GameDistributionContributionGeneration[];
|
||
directChildren: GameDistributionContributionChild[];
|
||
/** 参与计算的后代节点数(不含根)。 */
|
||
nodeCount: number;
|
||
truncated: boolean;
|
||
truncatedReason: string | null;
|
||
};
|
||
|
||
/**
|
||
* Fork 取件内容的形态。M2a 只有已构建的发行成品包;M2b 引入工程源包后,同一版本同时存在
|
||
* 两者时优先 `project`。
|
||
*/
|
||
export type GameDistributionForkSourceKind = 'package' | 'project';
|
||
|
||
/**
|
||
* Fork 取件元数据:客户端据此校验取到的内容并决定建项形态。
|
||
*
|
||
* 只含版本身份与摘要,**不含 OSS 对象键**——下载走同源受鉴权路径 `downloadPath`,
|
||
* 对象键只留在服务端。`sha256` / `bytes` 取自版本行已存的发行包摘要,不重新计算。
|
||
*/
|
||
export type GameDistributionForkSource = {
|
||
gameId: string;
|
||
versionId: string;
|
||
source: GameDistributionForkSourceKind;
|
||
sha256: string;
|
||
bytes: number;
|
||
/** 同源相对路径;客户端按当前 origin 解析后带 Bearer 请求,不接受绝对 URL。 */
|
||
downloadPath: string;
|
||
};
|
||
|
||
/** Fork 取件元数据接口(`GET …/fork-source`)的响应体。 */
|
||
export type GameDistributionForkSourceResponse = {
|
||
forkSource: GameDistributionForkSource;
|
||
};
|
||
|
||
export type GameDistributionGame = {
|
||
id: string;
|
||
title: string;
|
||
summary: string;
|
||
description: string;
|
||
category: GameDistributionCategory;
|
||
tags: string[];
|
||
coverColor: string;
|
||
icon: string;
|
||
/**
|
||
* 公开封面的 OSS objectKey;未上传封面时为空,展示层需回退到 coverColor/icon 占位。
|
||
* 通过 `/api/assets/read-url` 换签名 URL 读取,不接受直连外链。
|
||
*/
|
||
coverObjectKey?: string | null;
|
||
/** 公开截图的 OSS objectKey 列表,最多 6 张,随版本冻结。 */
|
||
screenshots?: string[];
|
||
author: GameDistributionAuthor;
|
||
deviceSupport: GameDistributionDeviceSupport;
|
||
inputModes?: GameDistributionInputMode[];
|
||
orientation?: GameDistributionOrientation;
|
||
status: Extract<GameDistributionGameVisibility, 'published' | 'unpublished'>;
|
||
/** 公开切换 CAS 版本号;作者下架与管理员审核都必须回传当前值。 */
|
||
publicationRevision: number;
|
||
/**
|
||
* 公开目录与公开详情一定带这一项(没有已公开版本时为 null);作者侧响应(我的作品列表、
|
||
* 创建、提交、审核、撤回)走服务端同一个 `game_payload`,不带这一项。
|
||
*/
|
||
currentVersion?: GameDistributionVersionSummary | null;
|
||
/** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */
|
||
ratingSummary?: GameDistributionRatingSummary;
|
||
/** 共创授权三态;旧数据或字段缺省时读作 `forbidden`。 */
|
||
forkAuthorization?: GameDistributionForkAuthorization;
|
||
/** 被改编次数(直接子代数量);旧数据或字段缺省时读作 0。 */
|
||
forkCount?: number;
|
||
/** 血缘摘要;根作品或旧数据为 null/缺省。 */
|
||
lineage?: GameDistributionLineage | null;
|
||
/**
|
||
* 当前登录用户是否已收藏(收录)该作品。
|
||
*
|
||
* **只有公开详情在登录时返回**:匿名请求(含携带无效 token 按匿名处理)不带这个键,
|
||
* 也不发 `false`——`false` 会把「未登录」说成「没收藏」,客户端没法区分,会渲染出错误的
|
||
* 收藏按钮态。公开目录不返回。值来自服务端真实投影,不是前端本地状态。
|
||
*/
|
||
collected?: boolean;
|
||
/**
|
||
* 该作品所属的**公开**共创主题(先取作品的根、再按根反查,跨主题多归属因此是数组)。
|
||
*
|
||
* **只有公开详情返回**(匿名与登录都返回;没有所属主题时是空数组,展示层据此不渲染空容器),
|
||
* 公开目录与作者侧不带这个键。第 N 代作品同样能看到主题——反查用的是根,不是作品自身。
|
||
* 排序与公开主题列表共用同一份比较器(创建时间倒序 + themeId 升序兜底)。
|
||
*/
|
||
themes?: GameDistributionThemeReference[];
|
||
/**
|
||
* 买断价(整数泥点,`0` 表示免费)。后端公开投影始终下发;作者侧与旧响应可省略
|
||
* (Rust 侧 `#[serde(default)]` 同样允许缺字段),读取方必须按 `0`(免费)兜底。
|
||
*/
|
||
priceMudPoints?: number;
|
||
/**
|
||
* 当前查看者是否已拥有;匿名与未登录恒为 `false`。旧响应可省略,读取方按未购买兜底。
|
||
*/
|
||
purchased?: boolean;
|
||
playCount: number;
|
||
createdAt: string;
|
||
};
|
||
|
||
export type GameDistributionListResponse = {
|
||
games: GameDistributionGame[];
|
||
nextCursor?: string | null;
|
||
};
|
||
|
||
/**
|
||
* 「我的收藏(收录)」列表接口(`GET /api/game-distribution/my-collections`)的响应体。
|
||
*
|
||
* 形状与公开目录 `GameDistributionListResponse` 一致(同一份公开投影),但分页语义不同:
|
||
* 默认一页 20 条、上限 50 条,排序按「收藏时间倒序 + collectionId 升序兜底」。
|
||
* `nextCursor` 是真实游标(`"{createdAtMicros}:{collectionId}"`),最后一页为 `null`;
|
||
* 游标格式非法时服务端返回 400(不是 200 的空页)。
|
||
*/
|
||
export type GameDistributionMyCollectionsResponse = {
|
||
games: GameDistributionGame[];
|
||
nextCursor?: string | null;
|
||
};
|
||
|
||
/**
|
||
* 共创主题的三态处置:`draft`(未发布)/ `published`(公开可见)/ `archived`(归档下架)。
|
||
*
|
||
* 公开侧只出现 `published`;`archived` 不是删除(主题行与成员行都保留,只是不再公开)。
|
||
* 后台写接口的 `status` 用这个联合类型,非法取值由服务端拒绝(400)。
|
||
*/
|
||
export type GameDistributionThemeStatus = 'draft' | 'published' | 'archived';
|
||
|
||
/**
|
||
* 作品详情「所属主题」增量的单个条目:只够渲染一个跳转入口(主题页里才有完整信息)。
|
||
*/
|
||
export type GameDistributionThemeReference = {
|
||
themeId: string;
|
||
name: string;
|
||
badge: string;
|
||
};
|
||
|
||
/**
|
||
* 公开主题列表的单条:主题名、简介、角标与**当前公开可见成员数**。
|
||
*
|
||
* `memberCount` 是**真实可见成员数**(不含草稿 / 已下架成员),服务端不回报「全部成员行数」。
|
||
* 它是真实数:可见成员超过响应体积上限(50)时,它与主题详情 `roots` 的长度**故意不相等**——
|
||
* 「这份 `roots` 被截断」由详情响应上的 `rootsTruncated` 单独表达,客户端不要用这个字段反推。
|
||
*/
|
||
export type GameDistributionThemeSummary = {
|
||
themeId: string;
|
||
name: string;
|
||
summary: string;
|
||
badge: string;
|
||
memberCount: number;
|
||
};
|
||
|
||
/**
|
||
* 公开主题列表接口(`GET /api/game-distribution/themes`)的响应体。
|
||
*
|
||
* 只含 `published` 主题。分页沿用既有游标惯例:默认 20 条、上限 50 条、超界截断;
|
||
* `nextCursor` 是真实游标(`"{createdAtMicros}:{themeId}"`),最后一页为 `null`;游标格式非法时
|
||
* 服务端返回 400(不是 200 的空页)。排序为创建时间倒序 + `themeId` 升序兜底(全序,不重不漏)。
|
||
*/
|
||
export type GameDistributionThemeListResponse = {
|
||
themes: GameDistributionThemeSummary[];
|
||
nextCursor?: string | null;
|
||
};
|
||
|
||
/**
|
||
* 公开主题详情接口(`GET /api/game-distribution/themes/{themeId}`)的响应体。
|
||
*
|
||
* 顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。`roots` 逐条是公开目录同一份
|
||
* 作品投影,排序为 `sortOrder` 升序 + `memberId` 升序兜底。主题不存在或未发布时接口返回 404
|
||
* (不返回空壳);已发布但可见成员为空时返回 200 + 空 `roots`(展示层按空态而不是错误渲染)。
|
||
*
|
||
* 两个成员数相关的字段口径**独立,不要互相推导**:
|
||
* - `memberCount` 是**真实可见成员数**(不截断),回答「这个主题有多少棵作品树」;
|
||
* - `rootsTruncated` 才是「**下面这份 `roots` 被响应体积上限(50)截断**」的信号,与族谱响应的
|
||
* `truncated` 同一个约定(列表被上限截断时置 `true`)。`roots.length !== memberCount` 时就得看它。
|
||
*/
|
||
export type GameDistributionThemeDetail = {
|
||
themeId: string;
|
||
name: string;
|
||
summary: string;
|
||
badge: string;
|
||
memberCount: number;
|
||
roots: GameDistributionGame[];
|
||
rootsTruncated: boolean;
|
||
};
|
||
|
||
/**
|
||
* 后台创建主题(`POST /admin/api/game-distribution/themes`)的请求体。
|
||
*
|
||
* `name` 必填且非空;其余字段省略时按「空文本 / 排序 0 / `draft`」落库(与表列默认一致)。
|
||
*/
|
||
export type GameDistributionCreateThemeRequest = {
|
||
name: string;
|
||
summary?: string;
|
||
badge?: string;
|
||
sortOrder?: number;
|
||
status?: GameDistributionThemeStatus;
|
||
};
|
||
|
||
/**
|
||
* 后台更新主题(`PUT /admin/api/game-distribution/themes/{themeId}`)的请求体。
|
||
*
|
||
* **整体覆盖**这几个字段(不是部分更新):字段都必填,避免「省略 = 保留旧值」与「省略 = 清空」
|
||
* 两种解释在客户端之间漂移。
|
||
*/
|
||
export type GameDistributionUpdateThemeRequest = {
|
||
name: string;
|
||
summary: string;
|
||
badge: string;
|
||
sortOrder: number;
|
||
status: GameDistributionThemeStatus;
|
||
};
|
||
|
||
/**
|
||
* 后台增 / 改成员(`PUT …/themes/{themeId}/members/{rootGameId}`)的请求体。
|
||
*
|
||
* 成员身份完全由路径给出,body 只承载运营权重;重复调用由确定性主键保证幂等。
|
||
*/
|
||
export type GameDistributionUpsertThemeMemberRequest = {
|
||
sortOrder?: number;
|
||
};
|
||
|
||
/**
|
||
* 后台主题条目:`{ themeId, name, summary, badge, sortOrder, status, memberCount, createdAt, updatedAt }`。
|
||
*
|
||
* 与公开的 `GameDistributionThemeSummary` **刻意不同形**:后台多出 `sortOrder` / `status` / 时间戳,
|
||
* 且 `memberCount` 的口径是**成员行总数**(含当前对外不可见的成员)——后台没有「不该被探测」的顾虑,
|
||
* 运营需要知道这个主题挂了几个根;公开投影里的同名键是**当前可见**成员数。
|
||
*
|
||
* 没有 Rust 结构体可对(服务端由 `admin_theme_payload` 逐字段手拼 JSON),因此在 DTO parity 里
|
||
* 走 `TS_ONLY_TYPES` + `RESPONSE_BUILDERS` 登记:形状由机器门禁钉住,后台 UI 与实现不能各自漂移。
|
||
*/
|
||
export type GameDistributionAdminTheme = {
|
||
themeId: string;
|
||
name: string;
|
||
summary: string;
|
||
badge: string;
|
||
sortOrder: number;
|
||
status: GameDistributionThemeStatus;
|
||
memberCount: number;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
};
|
||
|
||
/**
|
||
* 后台主题列表响应:`{ themes: [...] }`。
|
||
*
|
||
* **没有 `nextCursor`**:这条路径不分页(`limit` 缺省与上限都是 200),因此这里也不能声明可选游标,
|
||
* 否则前端会以为存在第二页。
|
||
*/
|
||
export type GameDistributionAdminThemeListResponse = {
|
||
themes: GameDistributionAdminTheme[];
|
||
};
|
||
|
||
/**
|
||
* 后台创建 / 更新主题的响应:`{ theme, replayed }`。
|
||
*
|
||
* `replayed` 必须原样发出:客户端据此区分「这次真的写了」与「同键重放,库里没动」。
|
||
*/
|
||
export type GameDistributionAdminThemeMutationResponse = {
|
||
theme: GameDistributionAdminTheme;
|
||
replayed: boolean;
|
||
};
|
||
|
||
/**
|
||
* 后台成员写入的响应:`{ themeId, rootGameId, sortOrder, createdAt }`。
|
||
*
|
||
* `createdAt` 是**首次挂载**的时间:重复 `PUT` 只改 `sortOrder`,不会刷新它。
|
||
*/
|
||
export type GameDistributionAdminThemeMemberResponse = {
|
||
themeId: string;
|
||
rootGameId: string;
|
||
sortOrder: number;
|
||
createdAt: string;
|
||
};
|
||
|
||
/**
|
||
* 后台移除成员的响应:`{ themeId, rootGameId }`。
|
||
*
|
||
* 刻意不含「之前存不存在」:删掉的与本来就没有的回同一个形状,重复调用逐字节相同。
|
||
*/
|
||
export type GameDistributionAdminThemeMemberRemovalResponse = {
|
||
themeId: string;
|
||
rootGameId: string;
|
||
};
|
||
|
||
/**
|
||
* 后台成员行的细粒度状态(**只给后台**,公开投影不发这个键)。
|
||
*
|
||
* - `published` / `unpublished` / `suspended`:成员行还在、游戏行也在,原样透传游戏行的可见性;
|
||
* - `deleted`:游戏行被软删除;
|
||
* - `missing`:游戏行**根本不存在**(历史脏行)。
|
||
*
|
||
* 与同一行的 `visible` **是两个独立字段、不许互相推导**:`visible` 回答「此刻公开投影会不会
|
||
* 出现它」,`visibility` 回答「为什么」。因此 `visibility === 'published'` 但没有当前公开版本时
|
||
* `visible === false`——这是刻意的,运营据此看出「作品已公开、但没有公开版本」这种异常。
|
||
*/
|
||
export type GameDistributionAdminThemeMemberVisibility =
|
||
| 'published'
|
||
| 'unpublished'
|
||
| 'suspended'
|
||
| 'deleted'
|
||
| 'missing';
|
||
|
||
/**
|
||
* 后台主题成员行:`GET /admin/api/game-distribution/themes/{themeId}/members` 的 `members[]`。
|
||
*
|
||
* **后台看得到全部成员行**(含当前对外不可见的),这是本接口存在的理由:草稿 / 归档主题在公开
|
||
* 投影里根本不存在,运营没法在发布前核对名单。
|
||
*
|
||
* `title` 取游戏行标题,游戏行不存在时为 `null`(该行仍要出现在名单里,否则与 `totalMembers` 对不上)。
|
||
* 只为后台渲染而发,后台本就允许看草稿作品。
|
||
*/
|
||
export type GameDistributionAdminThemeMemberRow = {
|
||
rootGameId: string;
|
||
title: string | null;
|
||
sortOrder: number;
|
||
/** 首次挂载时间(重复 PUT 只改 `sortOrder`,不刷新它)。 */
|
||
createdAt: string;
|
||
/** 此刻**公开投影**是否包含它(未删除 + 已公开 + 有当前公开版本)。 */
|
||
visible: boolean;
|
||
visibility: GameDistributionAdminThemeMemberVisibility;
|
||
};
|
||
|
||
/**
|
||
* 后台主题成员列表响应。
|
||
*
|
||
* `totalMembers` 是该主题的**成员行总数**(含当前不可见的成员),**不受分页影响**;它与
|
||
* `members.length` 不是一回事(后者只是这一页)。
|
||
*
|
||
* 分页与其它列表同一口径:`limit` 缺省 **20** / 上限 **50** / `0` 取默认 / 超界截断;游标形如
|
||
* `"{sortOrder}:{memberId}"`(`memberId` 本身是 `"{themeId}:{rootGameId}"`,解析只切第一个冒号),
|
||
* 排序为 `sortOrder` 升序 + 成员主键升序兜底(全序,翻页不重不漏);**非法游标 → 400
|
||
* `THEME_INVALID_CURSOR`**;末页 `nextCursor` 为 `null`。
|
||
*
|
||
* 主题不存在 → 404 `THEME_NOT_FOUND`;`draft` / `archived` **不是** 404(后台要看得见)。
|
||
*/
|
||
export type GameDistributionAdminThemeMemberListResponse = {
|
||
themeId: string;
|
||
totalMembers: number;
|
||
members: GameDistributionAdminThemeMemberRow[];
|
||
nextCursor: string | null;
|
||
};
|
||
|
||
export type GameDistributionCreateGameRequest = {
|
||
/** 发布方本地项目标识;同一作者重复发布会复用既有 gameId。 */
|
||
localProjectId?: string | null;
|
||
title: string;
|
||
summary: string;
|
||
description?: string;
|
||
category: GameDistributionCategory;
|
||
tags?: string[];
|
||
coverAssetId?: string;
|
||
/** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */
|
||
screenshots?: string[];
|
||
deviceSupport: GameDistributionDeviceSupport;
|
||
inputModes: GameDistributionInputMode[];
|
||
orientation: GameDistributionOrientation;
|
||
/**
|
||
* 上架时选择的共创授权档位:`forbidden` / `nonCommercial` / `full`。
|
||
*
|
||
* 缺省按「禁止共创」解释(与库表默认、与旧客户端行为一致);非法取值整请求 400,不会静默
|
||
* 落成 `forbidden`。上架之后只能通过 `PUT …/fork-authorization` 单向提升。
|
||
*
|
||
* **只对母版(0 代作品)生效**:带 `fork` 声明时,服务端在新作品创建时**继承父作品当时的
|
||
* 档位**,这里传的值被忽略且不报错(旧客户端惯常带默认值);服务端不接受「收窄」。
|
||
* 字段形状刻意保持不变——改成 `Option` 之类的形状会让创建请求的幂等摘要漂移。
|
||
*/
|
||
forkAuthorization?: GameDistributionForkAuthorization;
|
||
/** 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 */
|
||
fork?: GameDistributionForkDeclaration | null;
|
||
};
|
||
|
||
export type GameDistributionCreateVersionRequest = {
|
||
localProjectId?: string | null;
|
||
/**
|
||
* 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。
|
||
*
|
||
* 传入时只要求 `>= 1`:同一 `gameId` 的同一个版本号可以反复提交,每次提交生成新的
|
||
* `versionId`,允许重复标签与回退到更小的版本号,不与已有最大值比较。
|
||
* 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。
|
||
*/
|
||
versionNumber?: number | null;
|
||
/**
|
||
* 作者提交的买断价(整数泥点,`0` 表示免费)。
|
||
*
|
||
* 价格随版本冻结,审核通过时与资料一起生效到游戏行;缺省按 `0`(免费)处理。
|
||
* 取值范围由服务端约束:`0..=1_000_000`,付费必须是正整数。
|
||
*/
|
||
priceMudPoints?: number;
|
||
packageSha256: string;
|
||
packageBytes: number;
|
||
packageFileCount: number;
|
||
packageEntryPath: 'index.html';
|
||
gameMetadata: GameDistributionCreateGameRequest;
|
||
/**
|
||
* 「本次核心改动说明」:**衍生作品**(该作品有改编来源)发布新版本时必填,
|
||
* trim 后按**字符**计 20–500 个字符;0 代母版忽略该字段(不校验、不落库)。
|
||
*
|
||
* 缺失 → 400 `FORK_CHANGE_SUMMARY_REQUIRED`;长度越界 → 400 `FORK_CHANGE_SUMMARY_INVALID`。
|
||
* 它参与请求摘要:同一个 `Idempotency-Key` 换了说明会被按「同键不同请求」拒绝(409),
|
||
* 省略该键的旧客户端请求与升级前逐字节一致(缺省不序列化 `null`)。
|
||
*/
|
||
changeSummary?: string | null;
|
||
};
|
||
|
||
/**
|
||
* 作者编辑游戏级展示资料。
|
||
*
|
||
* 只覆盖游戏行上的展示字段;随版本冻结的包摘要与资料快照不受影响。
|
||
* `expectedPublicationRevision` 是公开切换 CAS,并发变化返回 409。
|
||
*/
|
||
export type GameDistributionUpdateGameMetadataRequest = {
|
||
expectedPublicationRevision: number;
|
||
title: string;
|
||
summary: string;
|
||
description?: string | null;
|
||
category: GameDistributionCategory;
|
||
tags?: string[];
|
||
coverAssetId?: string | null;
|
||
/** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */
|
||
screenshots?: string[];
|
||
deviceSupport: GameDistributionDeviceSupport;
|
||
inputModes: GameDistributionInputMode[];
|
||
orientation: GameDistributionOrientation;
|
||
};
|
||
|
||
export type GameDistributionPrivateVersion = {
|
||
versionId: string;
|
||
gameId: string;
|
||
versionNumber: number;
|
||
packageSha256: string;
|
||
packageBytes: number;
|
||
packageFileCount: number;
|
||
status: GameDistributionVersionStatus;
|
||
/** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */
|
||
publicationRevision: number;
|
||
/** 该版本冻结的买断价(整数泥点,`0` 表示免费);无价格的历史版本按免费口径处理。 */
|
||
priceMudPoints?: number;
|
||
reviewReason?: string | null;
|
||
/** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */
|
||
entryUrl: string | null;
|
||
/** 工程源包字节数;0 表示该版本未上传工程源包(作者上架时选择不给源码)。 */
|
||
projectBundleBytes: number;
|
||
/** 工程源包整包 SHA-256;未上传缺省。对象键不下发,只在服务端使用。 */
|
||
projectBundleSha256?: string | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
};
|
||
/**
|
||
* 版本状态的下一步动作,由服务端派生;客户端只按它渲染主行动作,不自行推断状态。
|
||
*/
|
||
export type GameDistributionRecoveryAction =
|
||
| 'upload'
|
||
| 'submit'
|
||
| 'wait'
|
||
| 'none'
|
||
| 'reupload'
|
||
| 'fix_package'
|
||
| 'fix_metadata';
|
||
|
||
/** 冻结资料里的截图:同时保留素材 ID(作者续发可复用)与对象键(展示换签用)。 */
|
||
export type GameDistributionFrozenScreenshot = {
|
||
assetId: string;
|
||
objectKey: string;
|
||
};
|
||
|
||
/**
|
||
* 随版本冻结的游戏资料快照。
|
||
*
|
||
* 只有作者本人(版本回读)与管理员(审核回读)会拿到素材 ID;公开投影只给对象键。
|
||
* 历史版本可能没有快照,读取方必须按空值处理。
|
||
*/
|
||
export type GameDistributionVersionFrozenMetadata = {
|
||
title?: string;
|
||
summary?: string;
|
||
description?: string;
|
||
category?: GameDistributionCategory;
|
||
tags?: string[];
|
||
coverAssetId?: string | null;
|
||
coverObjectKey?: string | null;
|
||
screenshots?: GameDistributionFrozenScreenshot[];
|
||
deviceSupport?: GameDistributionDeviceSupport;
|
||
inputModes?: GameDistributionInputMode[];
|
||
orientation?: GameDistributionOrientation;
|
||
};
|
||
|
||
export type GameDistributionVersionDetail = {
|
||
game: GameDistributionGame;
|
||
version: GameDistributionPrivateVersion & {
|
||
recoveryAction: GameDistributionRecoveryAction;
|
||
frozenMetadata?: GameDistributionVersionFrozenMetadata | null;
|
||
};
|
||
};
|
||
|
||
export type GameDistributionCancelVersionRequest = {
|
||
expectedPublicationRevision: number;
|
||
reason?: string;
|
||
};
|
||
|
||
export type GameDistributionCancelVersionResponse = {
|
||
game: GameDistributionGame;
|
||
version: GameDistributionPrivateVersion;
|
||
replayed: boolean;
|
||
};
|
||
|
||
/** 提升共创授权;`expectedForkAuthorization` 是 CAS 期望值,只允许升级。 */
|
||
export type GameDistributionSetForkAuthorizationRequest = {
|
||
expectedForkAuthorization: GameDistributionForkAuthorization;
|
||
forkAuthorization: GameDistributionForkAuthorization;
|
||
};
|
||
|
||
/** 创建游戏时的改编来源声明:父作品 + 建立血缘时锁定的父版本。 */
|
||
export type GameDistributionForkDeclaration = {
|
||
parentGameId: string;
|
||
parentVersionId: string;
|
||
};
|
||
|
||
/**
|
||
* 收藏(收录)写入 / 取消后的权威投影值。
|
||
*
|
||
* `collected` 是**调用后**的收藏事实(服务端真实投影,不是前端本地状态);PUT 与 DELETE
|
||
* 共用一个形状,客户端不按「动词」推断结果。`replayed` 只在带幂等键的 PUT 上有意义:
|
||
* 命中同键收据时为 `true`(本次没产生新事实);DELETE 按确定性主键删除、天然幂等,
|
||
* 因此不返回该键。
|
||
*/
|
||
export type GameDistributionCollectionState = {
|
||
collected: boolean;
|
||
replayed?: boolean;
|
||
};
|
||
|
||
/**
|
||
* 买断制购买请求体。
|
||
*
|
||
* 客户端带上自己看到的价格:与服务端当前价格不一致时返回 409,不会按旧价扣费。
|
||
*/
|
||
export type GameDistributionPurchaseRequest = {
|
||
expectedPriceMudPoints: number;
|
||
};
|
||
|
||
/** 购买记录快照:每账号每作品最多一条,成交价随购买冻结,之后调价不影响既有所有权。 */
|
||
export type GameDistributionPurchase = {
|
||
purchaseId: string;
|
||
gameId: string;
|
||
priceMudPoints: number;
|
||
createdAt: string;
|
||
};
|
||
|
||
/** 购买响应:`walletBalance` 是扣费后的泥点余额,`replayed` 表示服务端按同一幂等键重放。 */
|
||
export type GameDistributionPurchaseResponse = {
|
||
purchase: GameDistributionPurchase;
|
||
walletBalance: number;
|
||
replayed: boolean;
|
||
};
|
||
|
||
/**
|
||
* 播放会话响应:`playUrl` 是绑定「`gameId` + 用户 + 短时效」的发行入口。
|
||
* 结算入口地址后交给 iframe,会话过期或作品下架后不可继续播放。
|
||
*/
|
||
export type GameDistributionPlaySessionResponse = {
|
||
playUrl: string;
|
||
expiresAt: string;
|
||
};
|