e830034d03
- getGameDistributionMediaReadUrl / getOwnerGameDistributionMediaReadUrl 增加可选 AbortSignal 并透传 - resolveGamePublishImagePreview 转发 signal;useGameDistributionMediaReadUrl 在 effect 清理时 abort 在途请求
685 lines
23 KiB
TypeScript
685 lines
23 KiB
TypeScript
import type {
|
||
GameDistributionAuthor,
|
||
GameDistributionCancelVersionResponse,
|
||
GameDistributionCategory,
|
||
GameDistributionCreateGameRequest,
|
||
GameDistributionCreateVersionRequest,
|
||
GameDistributionDeviceSupport,
|
||
GameDistributionGame,
|
||
GameDistributionMyReviewResponse,
|
||
GameDistributionPlaySessionResponse,
|
||
GameDistributionPurchaseRequest,
|
||
GameDistributionPurchaseResponse,
|
||
GameDistributionRecoveryAction,
|
||
GameDistributionReviewsResponse,
|
||
GameDistributionSaveReviewRequest,
|
||
GameDistributionSaveReviewResponse,
|
||
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,
|
||
GameDistributionDeviceSupport,
|
||
GameDistributionGame,
|
||
GameDistributionInputMode,
|
||
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';
|
||
};
|
||
|
||
/**
|
||
* 发布/编辑媒体的原始二进制 part。
|
||
*
|
||
* `metadata` 里的 `coverObjectKey` / `screenshots` 槽位只描述“沿用线上图还是新图”;新图字节
|
||
* 由这里附带的 `File` 以 `cover` / `screenshot` part 随同一次 multipart 请求提交。
|
||
*/
|
||
export type GameDistributionPublishMediaParts = {
|
||
/** 新选择的本地封面;缺省时沿用 `metadata.coverObjectKey`。 */
|
||
cover?: File | null;
|
||
/** 与 `metadata.screenshots` 顺序一一对应的本地截图;`null` 表示该槽沿用 objectKey。 */
|
||
screenshots?: (File | null)[];
|
||
};
|
||
|
||
/** 游戏分发公开媒体换签响应;兼容 `read` 包装与平铺两种形状。 */
|
||
export type GameDistributionMediaReadUrlResponse = {
|
||
read?: {
|
||
objectKey?: string;
|
||
signedUrl?: string;
|
||
expiresAt?: string;
|
||
};
|
||
signedUrl?: string;
|
||
objectKey?: string;
|
||
expiresAt?: string;
|
||
};
|
||
|
||
const API_BASE = '/api/game-distribution';
|
||
|
||
/** 组装发布/编辑的 multipart 请求体:`metadata` 文本 part + 可选媒体二进制 part。 */
|
||
function buildGameDistributionFormData(
|
||
metadata: unknown,
|
||
media?: GameDistributionPublishMediaParts,
|
||
) {
|
||
const formData = new FormData();
|
||
formData.append('metadata', JSON.stringify(metadata));
|
||
if (media?.cover) {
|
||
formData.append('cover', media.cover);
|
||
}
|
||
for (const screenshot of media?.screenshots ?? []) {
|
||
if (screenshot) {
|
||
formData.append('screenshot', screenshot);
|
||
}
|
||
}
|
||
return formData;
|
||
}
|
||
|
||
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,服务端据此算真实购买态与是否下发付费入口;未登录时按匿名请求,
|
||
* 不主动补票、也不因 401 影响全局登录态——游客浏览详情不会被要求登录。
|
||
*/
|
||
const OPTIONAL_AUTH_REQUEST_OPTIONS: ApiRequestOptions = {
|
||
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;
|
||
};
|
||
|
||
/**
|
||
* 保存作品展示资料;包内容不在这里改动,仍随版本冻结。
|
||
*
|
||
* 走 `multipart/form-data`:`metadata` 文本 part 携带资料 DTO(含 `coverObjectKey` /
|
||
* `screenshots` 槽位),新选择的封面/截图作为 `cover` / `screenshot` 二进制 part 随同提交。
|
||
* 服务端复用创建游戏的资料校验(含“必须有封面、新图必须是图片”)。
|
||
*/
|
||
export async function updateMyGame(
|
||
gameId: string,
|
||
payload: GameDistributionUpdateGameMetadataRequest,
|
||
idempotencyKey: string,
|
||
media?: GameDistributionPublishMediaParts,
|
||
) {
|
||
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: buildGameDistributionFormData(payload, media),
|
||
},
|
||
'保存作品资料失败',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 软删除作品:成功后该作品从作者列表与公开投影里消失,历史版本包留在回收窗口内。
|
||
*
|
||
* 与其它写动作一致要带 `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 }),
|
||
},
|
||
'下架游戏失败',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 读取游戏公开详情。
|
||
*
|
||
* 走「可选鉴权」:公开资料对所有人可见,但已登录时必须带上身份,否则服务端算不出 `purchased`,
|
||
* 已购买用户也会拿到空的付费入口。
|
||
*/
|
||
export async function getGame(gameId: string) {
|
||
const normalizedId = gameId.trim();
|
||
if (!normalizedId) return null;
|
||
return requestJson<GameDistributionGame>(
|
||
`/games/${encodeURIComponent(normalizedId)}`,
|
||
{ method: 'GET', cache: 'no-store' },
|
||
'读取游戏详情失败',
|
||
OPTIONAL_AUTH_REQUEST_OPTIONS,
|
||
);
|
||
}
|
||
|
||
function buildGameDistributionMediaReadParams(
|
||
objectKey: string,
|
||
expireSeconds?: number,
|
||
) {
|
||
const normalizedKey = objectKey.trim().replace(/^\/+/u, '');
|
||
if (!normalizedKey) throw new Error('缺少游戏媒体标识');
|
||
const params = new URLSearchParams({ objectKey: normalizedKey });
|
||
if (
|
||
typeof expireSeconds === 'number' &&
|
||
Number.isFinite(expireSeconds) &&
|
||
expireSeconds > 0
|
||
) {
|
||
params.set('expireSeconds', String(Math.floor(expireSeconds)));
|
||
}
|
||
return params;
|
||
}
|
||
|
||
function readGameDistributionMediaSignedUrl(
|
||
payload: GameDistributionMediaReadUrlResponse | null,
|
||
) {
|
||
// requestJson 对空响应体会返回 null(见其实现),这里必须容忍 payload 为空,
|
||
// 否则会抛出原始 TypeError 而不是下面这句可控的友好错误。
|
||
const read = payload?.read ?? payload;
|
||
const signedUrl =
|
||
typeof read?.signedUrl === 'string' ? read.signedUrl.trim() : '';
|
||
if (!signedUrl) throw new Error('游戏媒体访问地址缺失');
|
||
return signedUrl;
|
||
}
|
||
|
||
/**
|
||
* 为公开的游戏封面/截图 objectKey 换取签名读地址。
|
||
*
|
||
* 游戏媒体落在项目快照桶、不经素材库 ACL,匿名可读;与 `/api/assets/read-url` 分离。
|
||
* 换签失败不在这里吞掉,由调用方决定占位或回退。
|
||
*/
|
||
export async function getGameDistributionMediaReadUrl(
|
||
objectKey: string,
|
||
expireSeconds?: number,
|
||
signal?: AbortSignal,
|
||
) {
|
||
const params = buildGameDistributionMediaReadParams(objectKey, expireSeconds);
|
||
const payload = await requestJson<GameDistributionMediaReadUrlResponse>(
|
||
`/media/read-url?${params.toString()}`,
|
||
{ method: 'GET', cache: 'no-store', signal },
|
||
'读取游戏媒体地址失败',
|
||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||
);
|
||
return readGameDistributionMediaSignedUrl(payload);
|
||
}
|
||
|
||
/**
|
||
* 为作者自己作品的封面/截图 objectKey 换取签名读地址。
|
||
*
|
||
* 走 owner 作用域 `GET /my-games/{gameId}/media/read-url`:未发布 / 待审 / 被驳回的作品也能
|
||
* 预览换签,判定只认「objectKey 命中本人作品当前行的媒体」;非本人或作品不存在返回 404。
|
||
*/
|
||
export async function getOwnerGameDistributionMediaReadUrl(
|
||
gameId: string,
|
||
objectKey: string,
|
||
expireSeconds?: number,
|
||
signal?: AbortSignal,
|
||
) {
|
||
const normalizedGameId = gameId.trim();
|
||
if (!normalizedGameId) throw new Error('缺少作品标识');
|
||
const params = buildGameDistributionMediaReadParams(objectKey, expireSeconds);
|
||
const payload = await requestJson<GameDistributionMediaReadUrlResponse>(
|
||
`/my-games/${encodeURIComponent(normalizedGameId)}/media/read-url?${params.toString()}`,
|
||
{ method: 'GET', cache: 'no-store', signal },
|
||
'读取作品媒体地址失败',
|
||
);
|
||
return readGameDistributionMediaSignedUrl(payload);
|
||
}
|
||
|
||
/**
|
||
* 上报一次「开始游戏」。调用方按 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,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 购买买断制作品。扣费与购买记录由服务端在同一事务内完成。
|
||
*
|
||
* - `Idempotency-Key` 必传:双击、并发或响应丢失重试都只会扣一次;
|
||
* - `expectedPriceMudPoints` 必须回传客户端看到的价格,服务端价格已变时返回 409;
|
||
* - 余额不足返回 400 与 `INSUFFICIENT_MUD_POINTS`,调用方据此给出充值入口。
|
||
*/
|
||
export async function purchaseGame(
|
||
gameId: string,
|
||
payload: GameDistributionPurchaseRequest,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedGameId = gameId.trim();
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedGameId) throw new Error('购买游戏缺少作品编号');
|
||
if (!normalizedKey) throw new Error('购买游戏缺少幂等键');
|
||
return requestJson<GameDistributionPurchaseResponse>(
|
||
`/games/${encodeURIComponent(normalizedGameId)}/purchase`,
|
||
{
|
||
method: 'POST',
|
||
cache: 'no-store',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
body: JSON.stringify(payload),
|
||
},
|
||
'购买游戏失败',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 为买断制作品签发播放会话。
|
||
*
|
||
* 免费游戏不调用这个接口;付费作品由服务端判定作者/管理员免购买与已购买,
|
||
* 未通过鉴权时拒绝并给出可读错误,客户端不得自行放行。
|
||
*/
|
||
export async function createGamePlaySession(gameId: string) {
|
||
const normalizedGameId = gameId.trim();
|
||
if (!normalizedGameId) throw new Error('创建播放会话缺少作品编号');
|
||
return requestJson<GameDistributionPlaySessionResponse>(
|
||
`/games/${encodeURIComponent(normalizedGameId)}/play-session`,
|
||
{ method: 'POST', cache: 'no-store' },
|
||
'创建播放会话失败',
|
||
);
|
||
}
|
||
|
||
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) },
|
||
'保存评价失败',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 创建游戏。
|
||
*
|
||
* 走 `multipart/form-data`:`metadata` 文本 part 携带资料 DTO,新封面/截图作为 `cover` /
|
||
* `screenshot` 二进制 part 按槽位顺序追加。
|
||
*/
|
||
export async function createGame(
|
||
payload: GameDistributionCreateGameRequest,
|
||
idempotencyKey: string,
|
||
media?: GameDistributionPublishMediaParts,
|
||
) {
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedKey) throw new Error('创建游戏缺少幂等键');
|
||
return requestJson<GameDistributionGame>(
|
||
'/games',
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
body: buildGameDistributionFormData(payload, media),
|
||
},
|
||
'创建游戏失败',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 在既有 gameId 下创建不可变新版本。
|
||
*
|
||
* `gameMetadata` 走 `metadata` 文本 part;新封面/截图作为 `cover` / `screenshot` part 提交,
|
||
* 沿用线上图时只在 `gameMetadata.coverObjectKey` / `gameMetadata.screenshots` 里带对象键。
|
||
*/
|
||
export async function createGameVersion(
|
||
gameId: string,
|
||
payload: GameDistributionCreateVersionRequest,
|
||
idempotencyKey: string,
|
||
media?: GameDistributionPublishMediaParts,
|
||
) {
|
||
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: buildGameDistributionFormData(payload, media),
|
||
},
|
||
'创建游戏版本失败',
|
||
);
|
||
}
|
||
|
||
/** 回读单个版本状态、驳回理由与服务端给出的恢复动作。 */
|
||
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,
|
||
},
|
||
'上传游戏发行包失败',
|
||
);
|
||
}
|