Files
Genarrative/src/services/gameDistributionClient.ts
T
suzmii 909f3d5fa2
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (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 / Repository checks (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
网页端共创主题:共创 Tab + 主题页(逐根内嵌作品树)+ 详情页主题入口 + 网关清单
- 客户端:新增 listThemes(游标分页,读 nextCursor)与 getTheme(404 由页面渲染成降级态);getGame 的返回类型增量 themes(匿名也返回;无主题时是空数组而不是缺键)
- 共创 Tab:广场页新增「游戏 / 共创」页签,复用既有 game-category-tabs 结构与类名,切到 /games/themes
- 主题列表页 GameThemesPage:卡片展示 name / summary / badge / memberCount;只有 nextCursor 非空才渲染「加载更多」并在点击时追加;空态与失败重试照既有写法
- 主题页 GameThemePage:标题 / 简介 / 角标 + 成员根清单;**逐根内嵌渲染作品树**——直接复用族谱页(GameLineagePage 新增 embedded 变体,只隐藏页面外壳:返回按钮 / 页头 / 脚注),树查询、可见性过滤、稳定排序与截断全部走 M3 那一份实现,不新建第二套树查询或树渲染(每根一次 /lineage 的 N+1 是技术方案 §3.10.4 已接受的取舍);已发布但可见成员为空 → 明确空态而不是错误;主题不存在 / 未发布 → 错误态
- 详情页:共创信息卡渲染所属主题角标(点击进主题页),第 N 代作品同样可见(服务端按根反查);无主题时不渲染空容器
- 路由登记:新增 /games/themes 与 /games/theme(详情用 ?id=),四处同批登记(STAGE_ROUTE_ENTRIES + SelectionStage 类型 + 壳层 lazy/回调/渲染分支 + 页面标题),并同步三份 nginx SPA allowlist 与 Pingora MAIN_SPA_PATHS:check:nginx-spa-routes(20 SPA routes / 3 模板)与 check:pingora-route-parity 均绿
- 测试:主题列表三例(渲染与卡片跳转 / 有游标时的加载更多与追加 / 空态与失败重试)、主题页三例(逐根两棵树且复用 lineage 查询 + 全屏入口参数 / 空成员空态 / 404 错误态)、详情页主题入口两例(渲染并可跳转 / 无主题不渲染容器)、路由与标题登记断言、壳层三例(页签切换 / 列表进主题页 / 主题页按 ?id= 渲染并复用族谱查询)
2026-10-06 03:45:38 +08:00

734 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
GameDistributionAuthor,
GameDistributionCancelVersionResponse,
GameDistributionCategory,
GameDistributionCreateGameRequest,
GameDistributionCreateVersionRequest,
GameDistributionDerivedResponse,
GameDistributionDeviceSupport,
GameDistributionForkAuthorization,
GameDistributionGame,
GameDistributionLineageResponse,
GameDistributionMyReviewResponse,
GameDistributionRecoveryAction,
GameDistributionReviewsResponse,
GameDistributionSaveReviewRequest,
GameDistributionSaveReviewResponse,
GameDistributionSetForkAuthorizationRequest,
GameDistributionUpdateGameMetadataRequest,
GameDistributionVersionDetail,
GameDistributionVersionStatus as GameDistributionVersionStatusValue,
GameDistributionVersionSummary,
} from '../../packages/shared/src/contracts/gameDistribution';
import {
type ApiRequestOptions,
BACKGROUND_AUTH_REQUEST_OPTIONS,
requestJson as requestApiJson,
} from './apiClient';
import { getGamePlayClientId } from './gamePlayClientId';
export type {
GameDistributionAuthor,
GameDistributionDerivedResponse,
GameDistributionDeviceSupport,
GameDistributionForkAuthorization,
GameDistributionGame,
GameDistributionInputMode,
GameDistributionLineage,
GameDistributionLineageNode,
GameDistributionLineageResponse,
GameDistributionRatingSummary,
GameDistributionRecoveryAction,
GameDistributionReview,
GameDistributionReviewsResponse,
GameDistributionVersionDetail,
GameDistributionVersionSummary,
} from '../../packages/shared/src/contracts/gameDistribution';
export type GameCategory = GameDistributionCategory;
export type GameDeviceSupport = GameDistributionDeviceSupport;
export type GameAuthor = GameDistributionAuthor;
export type GameVersionSummary = GameDistributionVersionSummary;
export type GameListQuery = {
authorId?: string;
keyword?: string;
category?: GameCategory | '全部';
};
export type GameVersionCreateResult = {
gameId: string;
versionId: string;
versionNumber: number;
status: 'awaiting_upload';
};
const API_BASE = '/api/game-distribution';
function normalizeGameList(value: unknown): GameDistributionGame[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is GameDistributionGame => {
if (!item || typeof item !== 'object') return false;
const game = item as Partial<GameDistributionGame>;
return Boolean(game.id && game.title && game.currentVersion);
});
}
const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = {
skipAuth: true,
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
/**
* 游玩上报是后台尽力而为的请求:带上已登录 bearer(后端据此按 userId 去重),但即使遇到 401
* 也不刷新会话、不改动全局登录态——直接复用后台请求已有的同一份鉴权降级策略。
*/
async function requestJson<T>(
path: string,
init: RequestInit,
fallbackMessage: string,
options?: ApiRequestOptions,
) {
return requestApiJson<T>(
`${API_BASE}${path}`,
{
...init,
headers: {
Accept: 'application/json',
...(init.body instanceof FormData ||
(typeof Blob !== 'undefined' && init.body instanceof Blob) ||
init.body instanceof ArrayBuffer
? {}
: init.body
? { 'Content-Type': 'application/json' }
: {}),
...init.headers,
},
},
fallbackMessage,
options,
);
}
export async function listGames(query: GameListQuery = {}) {
const params = new URLSearchParams();
if (query.authorId !== undefined) params.set('authorId', query.authorId);
if (query.keyword?.trim()) params.set('keyword', query.keyword.trim());
if (query.category && query.category !== '全部')
params.set('category', query.category);
const search = params.toString();
const payload = await requestJson<unknown>(
`/games${search ? `?${search}` : ''}`,
{ method: 'GET' },
'读取游戏目录失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
return normalizeGameList(
Array.isArray(payload) ? payload : (payload as { games?: unknown[] }).games,
);
}
export type GameDistributionMyGame = GameDistributionGame & {
latestVersion: GameDistributionVersionStatusEntry | null;
versions: GameDistributionVersionStatusEntry[];
};
export type GameDistributionVersionStatusEntry = {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: GameDistributionVersionStatusValue;
publicationRevision: number;
reviewReason?: string | null;
createdAt: string;
updatedAt: string;
/**
* 版本私有的发行入口;只有已公开版本有值,作者详情用它判定当前是否能在线游玩。
*/
entryUrl?: string | null;
/**
* 服务端派生的下一步动作。作者列表投影目前不带这一项(只有版本回读带),
* 缺失时前端只展示状态、不自行推断动作。
*/
recoveryAction?: GameDistributionRecoveryAction | null;
};
/**
* 作者自有游戏条目一定是「公开投影 + 私有版本状态」的形状;服务端缺字段时按空值兜底,
* 避免页面对半成品响应直接崩掉。
*/
function normalizeOwnerGameEntry(
value: unknown,
): GameDistributionMyGame | null {
if (!value || typeof value !== 'object') return null;
const entry = value as Partial<GameDistributionMyGame>;
if (!entry.id) return null;
const versions = Array.isArray(entry.versions)
? entry.versions.filter(
(item): item is GameDistributionVersionStatusEntry =>
Boolean(item && typeof item === 'object' && item.versionId),
)
: [];
return {
...(entry as GameDistributionMyGame),
latestVersion: entry.latestVersion ?? null,
versions,
};
}
export async function listMyGames() {
const payload = await requestJson<{ games?: unknown[] }>(
'/my-games',
{ method: 'GET' },
'读取我的游戏失败',
);
const games = Array.isArray(payload?.games) ? payload.games : [];
return games
.map(normalizeOwnerGameEntry)
.filter((game): game is GameDistributionMyGame => game !== null);
}
/**
* 作者读取自己名下单个作品:审核中 / 被驳回 / 已下架 / 已撤回的作品都能打开,
* 公开详情只服务已公开投影(作者点自己的未公开作品会拿到 404)。
*/
export async function getMyGame(
gameId: string,
): Promise<GameDistributionMyGame | null> {
const normalizedGameId = gameId.trim();
if (!normalizedGameId) return null;
const payload = await requestJson<{ game?: unknown }>(
`/my-games/${encodeURIComponent(normalizedGameId)}`,
{ method: 'GET' },
'读取作品详情失败',
);
return normalizeOwnerGameEntry(payload?.game);
}
/**
* 保存作品资料的成功响应:`game` 是公开投影形状(不含版本列表),
* `replayed` 表示服务端按同一个 Idempotency-Key 重放了这次写入。
*/
export type GameDistributionUpdateMyGameResponse = {
game: GameDistributionGame;
replayed: boolean;
};
/**
* 保存作品展示资料;包内容不在这里改动,仍随版本冻结。
*
* 服务端复用创建游戏的资料校验(含"必须有封面、截图必须是本人素材"),
* 因此编辑也必须带上当前封面/截图的素材 ID,否则资料不完整或会把线上截图清空。
*/
export async function updateMyGame(
gameId: string,
payload: GameDistributionUpdateGameMetadataRequest,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) throw new Error('缺少作品编号');
if (!normalizedKey) throw new Error('保存作品资料缺少幂等键');
return requestJson<GameDistributionUpdateMyGameResponse>(
`/my-games/${encodeURIComponent(normalizedGameId)}`,
{
method: 'PATCH',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify(payload),
},
'保存作品资料失败',
);
}
/**
* 软删除作品:成功后该作品从作者列表与公开投影里消失,历史版本包留在回收窗口内。
*
* 与其它写动作一致要带 `Idempotency-Key`;CAS 修订号走查询串(删除本身没有请求体),
* 并发下架/审核后过期由服务端拒绝。
*/
export async function deleteMyGame(
gameId: string,
expectedPublicationRevision: number,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) throw new Error('缺少作品编号');
if (!normalizedKey) throw new Error('删除作品缺少幂等键');
const params = new URLSearchParams({
expectedPublicationRevision: String(expectedPublicationRevision),
});
return requestJson<{ game?: GameDistributionGame; replayed?: boolean }>(
`/my-games/${encodeURIComponent(normalizedGameId)}?${params.toString()}`,
{
method: 'DELETE',
headers: { 'Idempotency-Key': normalizedKey },
},
'删除作品失败',
);
}
/**
* 补读版本回读里的 `recoveryAction`。
*
* 作者列表投影不带这一步动作,只为「还需要作者动作」的最新版本各回读一次;单个版本
* 回读失败只丢失该版本的下一步提示,不影响列表与其它作品。
*/
export async function loadVersionRecoveryActions(
versionIds: readonly string[],
): Promise<Record<string, GameDistributionRecoveryAction>> {
const uniqueVersionIds = [
...new Set(versionIds.map((versionId) => versionId.trim()).filter(Boolean)),
];
const details = await Promise.allSettled(
uniqueVersionIds.map((versionId) => getGameVersion(versionId)),
);
const actions: Record<string, GameDistributionRecoveryAction> = {};
uniqueVersionIds.forEach((versionId, index) => {
const detail = details[index];
if (detail?.status === 'fulfilled') {
actions[versionId] = detail.value.version.recoveryAction;
}
});
return actions;
}
export async function unpublishGame(
gameId: string,
expectedPublicationRevision: number,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) throw new Error('缺少游戏编号');
if (!normalizedKey) throw new Error('缺少幂等键');
return requestJson<{ game: { id: string; status: string } }>(
`/games/${encodeURIComponent(normalizedGameId)}/unpublish`,
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify({ expectedPublicationRevision }),
},
'下架游戏失败',
);
}
/**
* 提升作品共创授权(只升不降)。`expectedForkAuthorization` 为 CAS 期望值,
* 服务端发现档位冲突或降级时返回 409,错误信息由 `requestJson` 透传。
*/
export async function updateGameForkAuthorization(
gameId: string,
forkAuthorization: GameDistributionForkAuthorization,
expectedForkAuthorization: GameDistributionForkAuthorization,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) throw new Error('缺少游戏编号');
if (!normalizedKey) throw new Error('设置共创授权缺少幂等键');
const payload: GameDistributionSetForkAuthorizationRequest = {
expectedForkAuthorization,
forkAuthorization,
};
return requestJson<{
ok?: boolean;
replayed?: boolean;
game: {
id: string;
forkAuthorization?: GameDistributionForkAuthorization;
};
}>(
`/games/${encodeURIComponent(normalizedGameId)}/fork-authorization`,
{
method: 'PUT',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify(payload),
},
'设置共创授权失败',
);
}
export async function getGame(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionGameWithCollection>(
`/games/${encodeURIComponent(normalizedId)}`,
{ method: 'GET' },
'读取游戏详情失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
/**
* 公开详情在**已认证**时多带 `collected`;匿名请求**完全不返回该键**。
*
* 所以类型里它是可选的,读侧必须把「缺字段」与 `false` 区分开:缺字段 = 不可判定
* (未登录,或带过期 token 被服务端按匿名处理),不能渲染成「未收藏」。
*/
export type GameDistributionGameWithCollection = GameDistributionGame & {
collected?: boolean;
/**
* 该作品所属的公开共创主题(先取作品的根再按根反查,因此**第 N 代作品也会有值**)。
* 匿名也返回:没有所属主题时是 `[]` 而不是缺键。
*/
themes?: GameDistributionThemeReference[];
};
/** 作品详情里的主题入口:只够渲染一个跳转(简介与成员数在主题页取)。 */
export type GameDistributionThemeReference = {
themeId: string;
name: string;
badge: string;
};
/** 共创主题摘要(公开列表条目)。 */
export type GameDistributionThemeSummary = {
themeId: string;
name: string;
summary: string;
badge: string;
/** 当前**可见**成员数(与主题详情 `roots` 长度同口径,不含草稿/已下架成员)。 */
memberCount: number;
};
/** 主题详情:摘要 + 成员根清单(逐条是公开目录同一份投影)。 */
export type GameDistributionThemeDetail = GameDistributionThemeSummary & {
roots: GameDistributionGame[];
};
function normalizeThemeList(value: unknown): GameDistributionThemeSummary[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (!item || typeof item !== 'object') return [];
const theme = item as Partial<GameDistributionThemeSummary>;
const themeId = typeof theme.themeId === 'string' ? theme.themeId.trim() : '';
const name = typeof theme.name === 'string' ? theme.name.trim() : '';
if (!themeId || !name) return [];
return [
{
themeId,
name,
summary: typeof theme.summary === 'string' ? theme.summary : '',
badge: typeof theme.badge === 'string' ? theme.badge : '',
memberCount:
typeof theme.memberCount === 'number' && theme.memberCount >= 0
? theme.memberCount
: 0,
},
];
});
}
/**
* 共创主题列表(匿名可读)。分页与 `/my-collections` 同口径:服务端默认 20 / 上限 50,
* `nextCursor` 是真实游标(末页为 `null`),客户端只在非空时渲染「加载更多」。
*/
export async function listThemes(cursor?: string) {
const params = new URLSearchParams();
const normalizedCursor = cursor?.trim();
if (normalizedCursor) params.set('cursor', normalizedCursor);
const query = params.toString();
const payload = await requestJson<{
themes?: unknown;
nextCursor?: string | null;
}>(
`/themes${query ? `?${query}` : ''}`,
{ method: 'GET', cache: 'no-store' },
'读取共创主题失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
return {
themes: normalizeThemeList(payload.themes),
nextCursor: payload.nextCursor ?? null,
};
}
/**
* 共创主题详情(匿名可读)。主题不存在 / 未发布一律 404(服务端不返回空壳),
* 这里按失败抛出,由主题页渲染成「主题不存在或尚未发布」的降级态。
*/
export async function getTheme(
themeId: string,
): Promise<GameDistributionThemeDetail | null> {
const normalizedThemeId = themeId.trim();
if (!normalizedThemeId) return null;
const payload = await requestJson<{
themeId?: string;
name?: string;
summary?: string;
badge?: string;
memberCount?: number;
roots?: unknown;
}>(
`/themes/${encodeURIComponent(normalizedThemeId)}`,
{ method: 'GET', cache: 'no-store' },
'读取共创主题失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
return {
themeId: payload.themeId ?? normalizedThemeId,
name: payload.name ?? '',
summary: payload.summary ?? '',
badge: payload.badge ?? '',
memberCount: payload.memberCount ?? 0,
roots: normalizeGameList(payload.roots),
};
}
/** 收藏切换的响应;DELETE 不带 `replayed`。 */
export type GameDistributionCollectionState = {
collected: boolean;
replayed?: boolean;
};
/**
* 收藏 / 取消收藏。收藏走 PUT(幂等键由调用方生成,重复提交返回同一结果);
* 取消走 DELETE(确定性主键,重复调用结果相同,因此不需要幂等键)。
*/
export async function setGameCollection(
gameId: string,
collected: boolean,
idempotencyKey: string,
) {
const normalizedId = gameId.trim();
if (!normalizedId) throw new Error('收藏缺少作品编号');
if (!collected) {
return requestJson<GameDistributionCollectionState>(
`/games/${encodeURIComponent(normalizedId)}/collection`,
{ method: 'DELETE' },
'取消收藏失败',
);
}
const normalizedKey = idempotencyKey.trim();
if (!normalizedKey) throw new Error('收藏缺少幂等键');
return requestJson<GameDistributionCollectionState>(
`/games/${encodeURIComponent(normalizedId)}/collection`,
{
method: 'PUT',
headers: { 'Idempotency-Key': normalizedKey },
},
'收藏失败',
);
}
/**
* 「我的收藏」:只返回当前公开可读的收藏作品(下架/软删除的作品只是不出现,行不删除),
* 条目形状与公开目录逐字一致,因此可以复用同一套卡片组件渲染。
*
* `nextCursor` 服务端当前恒为 `null`(真分页是已知留白);这里按「将来会有 cursor」的形态
* 读出来并原样交给调用方,调用方只在非空时渲染「加载更多」。
*/
export async function listMyCollections() {
const payload = await requestJson<{
games?: unknown;
nextCursor?: string | null;
}>('/my-collections', { method: 'GET', cache: 'no-store' }, '读取我的收藏失败');
return {
games: normalizeGameList(payload.games),
nextCursor: payload.nextCursor ?? null,
};
}
/**
* 读取以某作品的根为顶的创作族谱树。公开只读接口,匿名可读;
* 作品不存在或已软删除时服务端返回 404,这里按失败抛出,由页面走既有错误态。
*/
export async function getGameLineage(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionLineageResponse>(
`/games/${encodeURIComponent(normalizedId)}/lineage`,
{ method: 'GET', cache: 'no-store' },
'读取创作族谱失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
/**
* 读取某作品的直接衍生作品(「被改编」列表)。只含对外可见的直接子代,
* 与公开详情里 `forkCount` 的口径一致。
*/
export async function getDerivedGames(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionDerivedResponse>(
`/games/${encodeURIComponent(normalizedId)}/derived`,
{ method: 'GET', cache: 'no-store' },
'读取衍生作品失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
/**
* 上报一次「开始游戏」。调用方按 fire-and-forget 处理:失败只静默忽略,绝不阻断进入游戏。
*/
export function recordGamePlay(
gameId: string,
): Promise<{ recorded?: boolean }> {
const normalizedId = gameId.trim();
if (!normalizedId) return Promise.resolve({ recorded: false });
return requestJson<{ recorded?: boolean }>(
`/games/${encodeURIComponent(normalizedId)}/plays`,
{
method: 'POST',
cache: 'no-store',
body: JSON.stringify({ clientId: getGamePlayClientId() }),
},
'游玩上报失败',
BACKGROUND_AUTH_REQUEST_OPTIONS,
);
}
export function listGameReviews(gameId: string, page = 1) {
return requestJson<GameDistributionReviewsResponse>(
`/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`,
{ method: 'GET', cache: 'no-store' },
'读取游戏评价失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
export function getMyGameReview(gameId: string) {
return requestJson<GameDistributionMyReviewResponse>(
`/games/${encodeURIComponent(gameId)}/my-review`,
{ method: 'GET', cache: 'no-store' },
'读取我的评价失败',
);
}
export function saveMyGameReview(
gameId: string,
payload: GameDistributionSaveReviewRequest,
) {
return requestJson<GameDistributionSaveReviewResponse>(
`/games/${encodeURIComponent(gameId)}/my-review`,
{ method: 'PUT', cache: 'no-store', body: JSON.stringify(payload) },
'保存评价失败',
);
}
export async function createGame(
payload: GameDistributionCreateGameRequest,
idempotencyKey: string,
) {
const normalizedKey = idempotencyKey.trim();
if (!normalizedKey) throw new Error('创建游戏缺少幂等键');
return requestJson<GameDistributionGame>(
'/games',
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify(payload),
},
'创建游戏失败',
);
}
export async function createGameVersion(
gameId: string,
payload: GameDistributionCreateVersionRequest,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedIdempotencyKey = idempotencyKey.trim();
if (!normalizedGameId || !normalizedIdempotencyKey) {
throw new Error('游戏版本创建缺少必要标识');
}
return requestJson<GameVersionCreateResult>(
`/games/${encodeURIComponent(normalizedGameId)}/versions`,
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedIdempotencyKey },
body: JSON.stringify(payload),
},
'创建游戏版本失败',
);
}
/** 回读单个版本状态、驳回理由与服务端给出的恢复动作。 */
export async function getGameVersion(versionId: string) {
const normalizedVersionId = versionId.trim();
if (!normalizedVersionId) throw new Error('缺少版本编号');
return requestJson<GameDistributionVersionDetail>(
`/versions/${encodeURIComponent(normalizedVersionId)}`,
{ method: 'GET' },
'读取版本状态失败',
);
}
/** 撤回尚未公开的版本;幂等键由调用方生成,重复提交返回同一结果。 */
export async function cancelGameVersion(
versionId: string,
expectedPublicationRevision: number,
idempotencyKey: string,
reason?: string,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) throw new Error('撤回版本缺少版本编号');
if (!normalizedKey) throw new Error('撤回版本缺少幂等键');
const normalizedReason = reason?.trim();
return requestJson<GameDistributionCancelVersionResponse>(
`/versions/${encodeURIComponent(normalizedVersionId)}/cancel`,
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify({
expectedPublicationRevision,
...(normalizedReason ? { reason: normalizedReason } : {}),
}),
},
'撤回版本失败',
);
}
export async function submitGameVersion(
versionId: string,
expectedPublicationRevision: number,
idempotencyKey: string,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) throw new Error('提交审核缺少版本 ID');
if (!normalizedKey) throw new Error('提交审核缺少幂等键');
return requestJson<{ version?: { status?: string } }>(
`/versions/${encodeURIComponent(normalizedVersionId)}/submit`,
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify({ expectedPublicationRevision }),
},
'提交审核失败',
);
}
export async function uploadGamePackage(
versionId: string,
packageBody: Blob | ArrayBuffer,
idempotencyKey: string,
) {
const normalizedVersionId = versionId.trim();
const normalizedIdempotencyKey = idempotencyKey.trim();
if (!normalizedVersionId || !normalizedIdempotencyKey) {
throw new Error('游戏发行包上传缺少必要标识');
}
return requestJson<{ versionId: string; status: 'uploaded' }>(
`/versions/${encodeURIComponent(normalizedVersionId)}/package`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/zip',
'Idempotency-Key': normalizedIdempotencyKey,
},
body: packageBody,
},
'上传游戏发行包失败',
);
}