Files
Genarrative/src/services/gameDistributionClient.ts
T
k88936 e830034d03 游戏媒体换签请求支持取消
- getGameDistributionMediaReadUrl / getOwnerGameDistributionMediaReadUrl 增加可选 AbortSignal 并透传
- resolveGamePublishImagePreview 转发 signal;useGameDistributionMediaReadUrl 在 effect 清理时 abort 在途请求
2026-10-07 14:11:59 +08:00

685 lines
23 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,
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,
},
'上传游戏发行包失败',
);
}